DEV Community

Cover image for TypeScript `TemplateStringsArray` and Tagged Templates: Building Type-Safe DSLs in 2026
jsmanifest
jsmanifest

Posted on Originally published at jsmanifest.com

TypeScript `TemplateStringsArray` and Tagged Templates: Building Type-Safe DSLs in 2026

TypeScript TemplateStringsArray and Tagged Templates: Building Type-Safe DSLs in 2026

This article was written with the assistance of AI, under human supervision and review.

Most template literal problems stem from treating dynamic strings as if they were simple concatenation. Teams reach for template literals when they need interpolation, but miss the deeper capability: tagged templates transform template literals into structured, type-safe function calls. The distinction matters because ordinary template literals lose all type information about their interpolated values, while tagged templates preserve and enforce those types at compile time.

The pattern that production codebases overlook is the TemplateStringsArray type. This built-in TypeScript type exposes the raw structure of template literals, giving developers the foundation to build domain-specific languages that catch errors before runtime. When a function tags a template literal, TypeScript passes the static string fragments as a TemplateStringsArray and the interpolated values as separate typed parameters.

The alternative approach wraps template literals with tag functions that enforce constraints at the type level. A tagged template receives the literal parts and interpolated expressions as distinct arguments, allowing the tag function to validate, transform, or reject values before constructing the final output.

Key Takeaways

  • TemplateStringsArray exposes the static string fragments of a template literal as a read-only array with a raw property containing unescaped versions.
  • Tagged template functions receive string fragments and interpolated values as separate parameters, enabling type-safe validation and transformation before output construction.
  • Production DSLs for HTML sanitization, SQL queries, and internationalization rely on tagged templates to prevent injection attacks and enforce structural constraints at compile time.
  • The choice between tagged templates and function calls depends on whether the syntax benefits from literal-first structure or whether runtime flexibility matters more than ergonomic syntax.
  • Raw string access through TemplateStringsArray.raw preserves escape sequences, critical for tools that generate code or process strings with literal backslashes.

Understanding TemplateStringsArray: The Hidden First Parameter

When a function tags a template literal, TypeScript invokes it with a specialized first argument of type TemplateStringsArray. This type represents the static string fragments between interpolation points, always containing one more element than the number of interpolated values.

The structure carries two critical properties. The array itself holds processed string fragments where escape sequences like \n become actual newlines. The raw property contains an identical array but with escape sequences preserved as literal characters. This distinction matters when building tools that generate source code or process strings where backslashes carry meaning.

function inspect(strings: TemplateStringsArray, ...values: unknown[]): void {
  console.log('Processed:', strings);
  console.log('Raw:', strings.raw);
  console.log('Values:', values);
}

const name = 'Alice';
const age = 30;

inspect`User: ${name}\nAge: ${age}`;
// Processed: [ 'User: ', '\nAge: ', '' ]
// Raw: [ 'User: ', '\\nAge: ', '' ]
// Values: [ 'Alice', 30 ]
Enter fullscreen mode Exit fullscreen mode

The empty string at the end of the fragments array appears because the template literal ends immediately after the final interpolation. Every interpolation point creates a boundary: the fragment before it, the interpolated value, and the fragment after it. A template literal with no interpolations produces an array with a single element.

The raw property exists specifically for scenarios where the developer needs the original source representation. A code generator that emits JavaScript must preserve backslashes in string literals. A regex builder that constructs patterns from template literals needs literal backslashes for escape sequences. Without access to raw strings, these tools would require users to double-escape every backslash, breaking the ergonomic benefit of template literals.

The type definition of TemplateStringsArray extends ReadonlyArray<string> and adds the raw property as a readonly string array. This inheritance means standard array methods like map, join, and slice work on the fragments, but mutation methods throw type errors. The immutability enforces a constraint: tag functions should not modify the input structure, only process it to produce output.

Building a Type-Safe HTML Sanitizer with Tagged Templates

HTML injection vulnerabilities occur when user input enters markup without escaping special characters. The classic mistake treats all interpolated values identically, allowing `` tags or event handlers to execute in the browser. A tagged template function can enforce escaping at the type level, rejecting values that have not been explicitly marked as safe.

`typescript
type SafeHTML = { __brand: 'SafeHTML'; value: string };

function escapeHTML(str: string): string {
return str
.replace(/&/g, '&')
.replace(/ .replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}

function markSafe(html: string): SafeHTML {
return { __brand: 'SafeHTML', value: html };
}

function html(
strings: TemplateStringsArray,
...values: (string | SafeHTML)[]
): SafeHTML {
let result = strings[0];

for (let i = 0; i < values.length; i++) {
const value = values[i];
const safeValue = typeof value === 'string'
? escapeHTML(value)
: value.value;
result += safeValue + strings[i + 1];
}

return markSafe(result);
}

// Usage
const userName = 'alert(&quot;XSS&quot;)';
const title = markSafe('Welcome');

const page = html
<h1>${title}</h1>
<p>Hello, ${userName}!</p>
;

console.log(page.value);
//

Welcome


//

Hello, <script>alert("XSS")</script>!


`

The SafeHTML branded type prevents accidental bypasses. A developer cannot pass raw HTML markup without explicitly calling markSafe, which signals intentional opt-in to unescaped content. The tag function checks each interpolated value: plain strings get escaped automatically, while SafeHTML values pass through unchanged.

This pattern extends to component libraries and templating systems. A framework might provide tag functions for different contexts: html for markup, css for styles, sql for queries. Each enforces context-appropriate escaping rules, and TypeScript prevents mixing values across contexts. The type system becomes a security boundary, not just documentation.

The failure mode here is subtle but expensive. Without type-level protection, a developer might forget to escape a single interpolation point, creating a vector for code injection. Manual code review catches some instances, but tagged templates eliminate the entire category of error. The compiler enforces safety, and unsafe operations require explicit opt-in.

Creating a SQL Query Builder DSL with Full Type Safety

SQL injection remains a persistent vulnerability because string concatenation cannot distinguish between query structure and user input. Prepared statements solve this problem by sending parameters separately from the query text, but building them manually with placeholder indices creates maintenance burden. A tagged template can automate placeholder generation while preserving full type safety.

`typescript
interface SQLQuery {
text: string;
values: unknown[];
}

function sql(
strings: TemplateStringsArray,
...values: unknown[]
): SQLQuery {
let text = strings[0];

for (let i = 0; i < values.length; i++) {
text += $${i + 1}${strings[i + 1]};
}

return { text, values };
}

// Usage with typed parameters
interface User {
id: number;
email: string;
}

async function findUser(email: string): Promise {
const query = sql
SELECT id, email
FROM users
WHERE email = ${email}
AND deleted_at IS NULL
;

// query.text: 'SELECT id, email FROM users WHERE email = $1 AND deleted_at IS NULL'
// query.values: ['user@example.com']

const result = await db.query(query.text, query.values);
return result.rows[0] ?? null;
}
`

The tag function constructs parameterized queries by replacing each interpolation point with a numbered placeholder. The database driver receives the query text and values as separate arguments, preventing any possibility of injection. The interpolated values never merge into the SQL string itself.

Advanced query builders extend this pattern with type inference. By analyzing the table schema, the builder can enforce that column names exist and that WHERE clause values match column types. A query that references a non-existent column or passes a string where an integer belongs fails at compile time.

`typescript
type ColumnType = T extends number ? number
: T extends string ? string
: T extends boolean ? boolean
: never;

type WhereClause = {
[K in keyof T]?: ColumnType | ColumnType[];
};

function buildWhere(
strings: TemplateStringsArray,
...values: Array>
): SQLQuery {
// Implementation details omitted for brevity
return { text: '', values };
}

interface Product {
id: number;
name: string;
price: number;
active: boolean;
}

// Type-safe: price is number
const validQuery = sqlSELECT * FROM products WHERE price < ${100};

// Type error: string not assignable to number
// const invalidQuery = sqlSELECT * FROM products WHERE price < ${"100"};
`

Production query builders combine tagged templates with schema introspection. Tools like Prisma or Kysely generate TypeScript types from database schemas, then use those types to validate queries at compile time. The tag function becomes a bridge between the runtime query execution and the compile-time type guarantees.

The implication here is that type safety extends beyond the application code into the database layer. A refactoring that renames a column or changes a type triggers compiler errors in every affected query. The feedback loop shortens from runtime crashes or integration test failures to immediate red squiggles in the editor.

Tagged Templates vs Function Calls: When Each Approach Wins

The decision between tagged templates and regular function calls comes down to syntax ergonomics versus runtime flexibility. Tagged templates excel when the static structure of the input matters more than dynamic composition, while function calls win when the construction logic needs conditional branches or loops.

Tagged templates enforce a literal-first API. The template string must appear at the call site, not constructed dynamically from variables. This constraint prevents accidentally building unsafe strings at runtime, but it also limits expressiveness. A query builder that needs to construct WHERE clauses from an unknown number of conditions cannot use tagged templates alone.

Function calls provide full runtime flexibility. A builder function can accept arrays of conditions, loop over them to construct clauses, and conditionally include fragments based on runtime values. The tradeoff is that the function cannot leverage TypeScript's template literal type inference, so type safety requires explicit generic parameters or overloads.

`typescript
// Tagged template: ergonomic but inflexible
const query1 = sql
SELECT * FROM users
WHERE email = ${email}
AND age > ${minAge}
;

// Function call: flexible but verbose
interface QueryBuilder {
table(name: string): QueryBuilder;
where(column: string, value: unknown): QueryBuilder;
build(): SQLQuery;
}

const query2 = new QueryBuilder()
.table('users')
.where('email', email)
.where('age', minAge)
.build();
`

The pattern that works in production combines both approaches. Use tagged templates for the common case where developers write literal queries, and provide escape hatches for dynamic construction. A query builder might expose both a sql tag function and a sql.dynamic() method that accepts an array of fragments and values.

Libraries like styled-components demonstrate this hybrid approach. The primary API uses tagged templates for ergonomic component styling, but the library also exports functions for runtime theme logic and conditional styles. Developers reach for the tagged template 90% of the time, and the function API covers edge cases.

The failure mode is forcing tagged templates into scenarios where they do not fit. A developer who tries to build a dynamic query by concatenating template literals defeats the entire safety mechanism. The correct solution recognizes when the use case needs runtime flexibility and switches to an appropriate API that maintains type safety through different mechanisms.

Production Patterns: Internationalization, Routing, and Styled Components

Internationalization libraries use tagged templates to enforce placeholder consistency across translations. A translation key must interpolate the same variables in every language, preventing runtime errors where a translation references a missing parameter. The tag function validates that every interpolation point has a corresponding value in the provided object.

`typescript
type TranslationKey = {
en: string;
es: string;
fr: string;
};

function t(
strings: TemplateStringsArray,
...keys: string[]
): (values: Record) => string {
return (values) => {
const locale = getCurrentLocale();
const translations: Record = getTranslations();

let result = strings[0];
for (let i = 0; i < keys.length; i++) {
  const key = keys[i];
  if (!(key in values)) {
    throw new Error(`Missing translation value: ${key}`);
  }
  result += String(values[key]) + strings[i + 1];
}

return result;
Enter fullscreen mode Exit fullscreen mode

};
}

// Define translations
const greeting = tHello, ${'name'}! You have ${'count'} messages.;

// Use with values
console.log(greeting({ name: 'Alice', count: 5 }));
// "Hello, Alice! You have 5 messages."
`

Type-safe routing libraries leverage tagged templates to generate URLs with compile-time guarantees that all parameters exist. A route template defines the structure, and the tag function ensures that every dynamic segment receives a value when generating a URL. The type system prevents typos in parameter names and catches missing values.

Styled-components pioneered the use of tagged templates for CSS-in-JS. The library interprets template literals as CSS text, provides editor highlighting and autocomplete, and generates optimized stylesheets at runtime. Interpolated values can reference theme objects, component props, or dynamic calculations, all with full TypeScript support.

`typescript
import styled from 'styled-components';

interface ButtonProps {
variant: 'primary' | 'secondary';
size: 'small' | 'large';
}

const Button = styled.button`
padding: ${props => props.size === 'small' ? '8px 16px' : '12px 24px'};
background-color: ${props =>
props.variant === 'primary' ? '#007bff' : '#6c757d'
};
color: white;
border: none;
border-radius: 4px;
font-size: ${props => props.size === 'small' ? '14px' : '16px'};

&:hover {
opacity: 0.8;
}
`;

// Usage with type-safe props
Click me
`

The critical insight across these patterns is that tagged templates provide a DSL that feels like native syntax while maintaining full TypeScript type checking. A developer writing a translation key, a route path, or a styled component uses familiar template literal syntax, but the compiler enforces domain-specific constraints that plain strings cannot express.

Production codebases benefit most when the tagged template API aligns with the domain model. Internationalization naturally maps to template literals with named placeholders. Route definitions read like URL patterns with dynamic segments. CSS-in-JS mirrors the structure of actual stylesheets. The ergonomics come from matching the syntax to the problem domain.

Common Pitfalls: Type Inference, Raw Strings, and Security

Type inference for tagged templates fails when TypeScript cannot determine the relationship between string fragments and interpolated values. A tag function that returns a complex union type might produce any or overly broad types if the function signature does not guide the compiler. The solution is explicit generic parameters or overloads that narrow the return type based on the input.

`typescript
// Poor: loses type information
function query(strings: TemplateStringsArray, ...values: unknown[]): unknown {
// Implementation
return {};
}

const result = querySELECT * FROM users WHERE id = ${1};
// result: unknown

// Better: explicit return type
function queryTyped(
strings: TemplateStringsArray,
...values: unknown[]
): T {
// Implementation
return {} as T;
}

interface User {
id: number;
email: string;
}

const typedResult = queryTypedSELECT * FROM users WHERE id = ${1};
// typedResult: User
`

Raw string access through TemplateStringsArray.raw exposes escape sequences, but developers often forget that this property exists. A tag function that builds regular expressions or generates code needs raw strings to preserve backslashes. Using the processed strings produces incorrect output because \n becomes a newline instead of a literal backslash followed by n.

Security vulnerabilities emerge when developers bypass the safety mechanisms of tagged templates. A function that accepts both tagged templates and plain strings cannot distinguish between sanitized and unsanitized input. The type system offers no protection if the implementation treats all strings identically.

`typescript
// Dangerous: accepts both tagged and untagged strings
function dangerousHTML(
input: TemplateStringsArray | string,
...values: unknown[]
): string {
if (typeof input === 'string') {
return input; // No sanitization!
}
// Tagged template path with sanitization
let result = input[0];
for (let i = 0; i < values.length; i++) {
result += escapeHTML(String(values[i])) + input[i + 1];
}
return result;
}

// This compiles but bypasses sanitization
const unsafe = dangerousHTML('alert(&quot;XSS&quot;)');
`

The correct approach splits the API: one function accepts tagged templates and enforces safety, while a separate function handles plain strings but requires explicit acknowledgment of the risk. The type signatures prevent accidental mixing, and naming conventions signal the security implications.

Performance considerations matter when tag functions execute inside hot paths. Each tagged template invocation allocates a new array for the string fragments and another for the interpolated values. A tag function called thousands of times per second can create garbage collection pressure. Memoization or pooling strategies mitigate this cost in performance-critical code.

`typescript
const cache = new Map();

function memoizedTag(
strings: TemplateStringsArray,
...values: unknown[]
): string {
let result = cache.get(strings);

if (!result) {
result = strings[0];
for (let i = 0; i < values.length; i++) {
result += \${${i}} + strings[i + 1];
}
cache.set(strings, result);
}

return result.replace(/\${(\d+)}/g, (_, i) => String(values[i]));
}
`

The failure mode here is assuming that tagged templates come with no runtime cost. The convenience of the syntax hides allocations and function calls that add up in aggregate. Profiling reveals whether the overhead matters for a specific use case, and optimization techniques exist when it does.

Frequently Asked Questions

What is the difference between TemplateStringsArray and a regular string array?

TemplateStringsArray is a read-only array type that adds a raw property containing the original string fragments with escape sequences preserved. Regular string arrays are mutable and lack this property. TypeScript uses TemplateStringsArray specifically for the first parameter of tagged template functions to provide access to both processed and raw string data.

Can tagged template functions return promises or other async values?

Tagged template functions can return any type including promises. The tag function signature determines the return type, and callers must handle it appropriately. Async tag functions enable use cases like database queries that need to await results before returning the final value. The await keyword works naturally with promises returned from tagged templates.

How do tagged templates handle nested template literals?

Nested template literals evaluate from the inside out. The innermost template literal executes first, produces a string, and that string becomes an interpolated value in the outer template. If the outer template has a tag function, it receives the fully evaluated inner result as one of its value parameters. Each level of nesting creates a separate function invocation with its own TemplateStringsArray.

Why does TemplateStringsArray extend ReadonlyArray instead of Array?

The immutability constraint prevents tag functions from modifying the input structure. Since the template literal syntax is static at the call site, allowing mutations would create confusion between the source code representation and the runtime value. The compiler enforces that tag functions treat the string fragments as immutable data.

Can you use tagged templates with generic type parameters?

Tagged template functions support generic type parameters like any other function. The generics can constrain the types of interpolated values or determine the return type based on input types. Type inference works best when the generic parameters have clear relationships to the function signature, allowing TypeScript to infer types without explicit annotations at the call site.

Conclusion: When Tagged Templates Are the Right Tool

Tagged templates solve a specific category of problem: building domain-specific languages where syntax ergonomics matter and type safety cannot compromise. The pattern works when the structure of the input is known at write time and the interpolated values need validation or transformation before output construction.

Production use cases fall into three categories: preventing injection attacks through automatic escaping, enforcing structural constraints like parameter presence, and providing familiar syntax for specialized domains. HTML sanitizers, SQL builders, internationalization systems, routing libraries, and CSS-in-JS all benefit from the combination of readable syntax and compile-time guarantees.

The tradeoff is loss of runtime flexibility. Tagged templates cannot express logic that depends on dynamic conditions or loops over collections. When a use case needs that flexibility, function-based APIs remain the better choice. The decision comes down to whether the syntax benefits justify the constraints.

That covers the essential patterns for TypeScript tagged templates and TemplateStringsArray. Apply these in production and the difference will be immediate: fewer runtime errors, better developer experience, and type-safe DSLs that feel like native language features.

Top comments (1)

Collapse
 
alexcodebytes profile image
Oleksandr •

Excellent breakdown of an underutilized TypeScript capability. Moving teams away from basic string interpolation toward compile-time enforced Tagged Templates is one of the most effective ways to eliminate SQL injection and malformed query bugs before code ever hits production.
Your focus on the internal structure of TemplateStringsArray points out a critical runtime reality:
• Preserving the Raw Interleaved Geometry: Ordinary literals force early evaluation and discard the semantic difference between static syntax blocks and dynamic values. Accessing the raw string array enables deep structural validation because it guarantees that the execution wrapper treats user input strictly as a parameter node rather than executable code.
To take this pattern even further when building type-safe domain-specific languages (DSLs), consider combining TemplateStringsArray parsing with TypeScript's Template Literal Types introduced in recent versions. By mapping the static string fragments directly to specific recursive tuple types or literal object shapes at compile time, you can block invalid syntax configurations inside the editor loop itself, turning standard runtime parsing panics into immediate, loud IDE compilation failures.