Template Literal Types in TypeScript Through Examples

1. Overview

Template literal types allow you to use backtick syntax directly in TypeScript’s type system. This lets you concatenate, transform, and parse string types at compile time. This article goes through each feature with concrete examples: combining unions, renaming keys in mapped types, extracting parameters from routes, and writing type-safe event emitters. After reading it, you’ll know when to use this technique. You’ll also know how to test it and which spots can make the compiler slow or produce confusing errors.

All examples in this article use TypeScript 5.x with strict: true.

2. Basic Syntax

A template literal type looks exactly like a template string at runtime. The difference is that the interpolation part ${...} contains a type, not a value:

type OrderId = `ORD-${number}`;

const ok: OrderId = "ORD-1024";
// const bad: OrderId = "INV-1024";
// Error: Type '"INV-1024"' is not assignable to type '`ORD-${number}`'.

The OrderId type accepts any string that starts with ORD- followed by a numeric string. You can interpolate string, number, bigint, boolean, null, undefined, and their corresponding literal types.

A familiar example is a CSS value with units:

type Pixel = `${number}px`;

function setWidth(el: HTMLElement, width: Pixel): void {
  el.style.width = width;
}

setWidth(document.body, "320px"); // OK
// setWidth(document.body, "320");  // Error: missing "px"

The compiler now catches the forgotten unit. Before, you’d only discover this bug when you opened the browser.

3. Combining with Unions: Cartesian Product

When the interpolation is a union, TypeScript distributes the template across each member. If there are multiple unions, the result includes every possible combination:

type Size = "sm" | "md" | "lg";
type Variant = "primary" | "danger";

type ButtonClass = `btn-${Size}-${Variant}`;

Hover over ButtonClass in your editor and you’ll see:

type ButtonClass = "btn-sm-primary" | "btn-sm-danger" | "btn-md-primary"
                 | "btn-md-danger" | "btn-lg-primary" | "btn-lg-danger"

Here, 3 × 2 = 6 members are generated automatically. When you add "xl" to Size, ButtonClass updates itself. You don’t have to edit anything else.

Note: The number of combinations grows exponentially. If the resulting union exceeds 100,000 members, TypeScript will report an error: “Expression produces a union type that is too complex to represent”. Don’t combine multiple large unions together—for example, a union of 1,000 product codes with a union of 200 country codes.

4. Built-in String Transformation Types

TypeScript provides four intrinsic types to change case:

type Event = "orderCreated";

type A = Uppercase<Event>;     // "ORDERCREATED"
type B = Lowercase<Event>;     // "ordercreated"
type C = Capitalize<Event>;    // "OrderCreated"
type D = Uncapitalize<"OrderCreated">; // "orderCreated"

These are built into the compiler, not written in TypeScript. These four types often pair with template literals to generate names following conventions, such as on${Capitalize<K>} or ${Uppercase<K>}_ENV.

type ConfigKey = "databaseUrl" | "apiToken";
type EnvName = `APP_${Uppercase<ConfigKey>}`;
// "APP_DATABASEURL" | "APP_APITOKEN"

5. Renaming Keys in Mapped Types

This is where template literal types shine the most. The as clause in mapped types lets you rename each key as you construct the new type.

5.1. Generating Getters from an Interface

interface Customer {
  name: string;
  email: string;
  loyaltyPoints: number;
}

type Getters<T> = {
  [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};

type CustomerGetters = Getters<Customer>;

The result:

type CustomerGetters = {
  getName: () => string;
  getEmail: () => string;
  getLoyaltyPoints: () => number;
}

Breaking down each part:

  • keyof T & string filters out keys of type number and symbol, since Capitalize only accepts string. Without it, you’ll get the error “Type ‘K’ does not satisfy the constraint ‘string’”.
  • as \get${Capitalize}`renamesnametogetName`.
  • () => T[K] preserves the correct return type of the original property.

A class that implements this type will have compiler errors right away if a getter is missing or returns the wrong type:

class CustomerView implements CustomerGetters {
  constructor(private readonly data: Customer) {}
  getName = () => this.data.name;
  getEmail = () => this.data.email;
  getLoyaltyPoints = () => this.data.loyaltyPoints;
}

5.2. Filtering Keys by Returning never

If the expression in as returns never, that key is excluded from the result. You can combine this with template literals to select keys by prefix:

interface PaymentService {
  chargeCard(amount: number): Promise<void>;
  chargeWallet(amount: number): Promise<void>;
  refund(paymentId: string): Promise<void>;
}

type ChargeMethods<T> = {
  [K in keyof T as K extends `charge${string}` ? K : never]: T[K];
};

type OnlyCharge = ChargeMethods<PaymentService>;
// { chargeCard: ...; chargeWallet: ... }

refund doesn’t match the charge${string} pattern, so it’s excluded.

6. Parsing Strings with infer

Template literals aren’t just for concatenating strings. You can also use them to split strings in conditional types, combined with infer.

6.1. Extracting a Simple Part

type StripPrefix<S extends string> =
  S extends `ORD-${infer Rest}` ? Rest : never;

type X = StripPrefix<"ORD-1024">; // "1024"
type Y = StripPrefix<"INV-1024">; // never

When the pattern matches, TypeScript binds the remaining part to Rest.

6.2. Extracting Parameters from a Route

This is the most common real-world example. From the string "/users/:userId/orders/:orderId", we want the type "userId" | "orderId":

type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? Param
      : never;

type P = ExtractParams<"/users/:userId/orders/:orderId">;
// "userId" | "orderId"

How it works:

  1. The first branch matches "/users/" + ":" + "userId" + "/" + "orders/:orderId". Each placeholder matches up to the first occurrence of the literal character following it. So Param is "userId".
  2. Recurse with "/orders/:orderId". This time, there’s no / after the parameter, so the first branch fails.
  3. The second branch matches and returns "orderId".
  4. If the string has no :, the result is never.

Now use it for a URL-building function:

type RouteParams<Path extends string> = {
  [K in ExtractParams<Path>]: string;
};

function buildUrl<Path extends string>(
  path: Path,
  params: RouteParams<Path>,
): string {
  const values = params as Record<string, string>;
  return path.replace(/:(\w+)/g, (_, key: string) =>
    encodeURIComponent(values[key]),
  );
}

const url = buildUrl("/users/:userId/orders/:orderId", {
  userId: "42",
  orderId: "ORD-7",
});
console.log(url);

// buildUrl("/users/:userId", { id: "42" });
// Error: Object literal may only specify known properties, and 'id' does not exist...

Output:

/users/42/orders/ORD-7

The generic Path is inferred as a literal type from the first argument. Because of this, the params object is type-checked precisely: missing keys, extra keys, or typos are all caught. The cast to Record<string, string> in the function body is necessary, because TypeScript can’t prove that a key extracted from regex at runtime belongs to ExtractParams<Path>.

7. Complete Example: Type-Safe Event Emitter

Combining the techniques above, we get an emitter for an order domain:

type OrderEvents = {
  created: { orderId: string; total: number };
  shipped: { orderId: string; carrier: string };
  cancelled: { orderId: string; reason: string };
};

type EventHandlers<E> = {
  [K in keyof E & string as `on${Capitalize<K>}`]?: (payload: E[K]) => void;
};

class OrderEmitter {
  constructor(private readonly handlers: EventHandlers<OrderEvents>) {}

  emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
    const name = `on${event[0].toUpperCase()}${event.slice(1)}` as
      `on${Capitalize<K>}`;
    const handler = this.handlers[name] as
      ((p: OrderEvents[K]) => void) | undefined;
    handler?.(payload);
  }
}

const emitter = new OrderEmitter({
  onCreated: (p) => console.log(`Created ${p.orderId}: ${p.total}`),
  onShipped: (p) => console.log(`Shipped via ${p.carrier}`),
});

emitter.emit("created", { orderId: "ORD-1", total: 250_000 });
emitter.emit("shipped", { orderId: "ORD-1", carrier: "GHN" });
// emitter.emit("shipped", { orderId: "ORD-1", reason: "x" }); // Error

Output:

Created ORD-1: 250000
Shipped via GHN

What you get:

  • Handler names onCreated, onShipped, onCancelled are generated automatically from OrderEvents.
  • The parameter p in each handler is inferred with the correct payload type—no declaration needed.
  • emit("shipped", ...) requires the payload to have carrier.

The two as casts in emit are where we tell the compiler something it can’t infer on its own. The string built at runtime carries no type information. Keep such casts in a small scope, confined to the implementation. The public API remains completely type-safe.

8. Testing Types with Vitest

Type-level logic deserves testing too. Without it, a refactor could turn ExtractParams into string without anyone noticing. Install Vitest:

npm install -D vitest typescript

Write tests for both type behavior and runtime behavior:

// route.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { buildUrl, type ExtractParams } from "./route";

describe("ExtractParams", () => {
  it("extracts parameter names correctly", () => {
    expectTypeOf<ExtractParams<"/users/:userId/orders/:orderId">>()
      .toEqualTypeOf<"userId" | "orderId">();
  });

  it("returns never when no parameters exist", () => {
    expectTypeOf<ExtractParams<"/health">>().toBeNever();
  });
});

describe("buildUrl", () => {
  it("replaces parameters and encodes values", () => {
    const url = buildUrl("/customers/:customerId", { customerId: "a b" });
    expect(url).toBe("/customers/a%20b");
  });

  it("rejects wrong keys", () => {
    // @ts-expect-error - 'id' is not a parameter of this route
    buildUrl("/customers/:customerId", { id: "1" });
  });
});

Run:

npx tsc --noEmit && npx vitest run

expectTypeOf is a no-op at runtime. Its errors only appear during type-check, so running tsc --noEmit (or vitest --typecheck) is mandatory. The @ts-expect-error directive is also an assertion. If the line below no longer has a type error, tsc will report “Unused ‘@ts-expect-error’ directive”.

9. Common Pitfalls and When Not to Use

Note: Template literal types exist only at compile time. Data from APIs, forms, or process.env isn’t checked at all. With external data, you still need to validate at runtime using regex or a schema library.

Some specific pitfalls:

  • ${number} is broader than you think. "1e3px" and "0.5px" both match `${number}px`. If you need “positive integer”, template literals can’t express that. Check at runtime.
  • Union explosion. As in section 3, combining multiple large unions makes the compiler slow and can even report the type is too complex.
  • Deep recursion. Recursive types like ExtractParams have depth limits. With very long strings, you’ll hit “Type instantiation is excessively deep and possibly infinite”. Normal routes don’t reach this limit, but general string parsers might.
  • Hard-to-read error messages. With complex types, errors can span dozens of lines. If a colleague has to read that error every day, the cost may outweigh the benefit.
  • Missing & string. keyof T can contain number | symbol, which Capitalize will reject.
  • Overuse. If the value set is small and fixed, write a union literal directly. type Size = "sm" | "md" is easier to read than any generated type.

Use it when you have a single source of truth (an interface, event object, or route string) and you want other types to be automatically derived from it. Don’t use it just to show off type-level skills.

10. Conclusion

Template literal types make strings first-class citizens in TypeScript’s type system. This article covered the main techniques:

  • concatenating strings and distributing across unions;
  • changing case with Uppercase, Capitalize, and others;
  • renaming and filtering keys in mapped types with as;
  • splitting strings with infer, including recursive styles.

Applied correctly—on route parameters, event names, or getters—you eliminate an entire class of typos that would otherwise only surface at runtime. Remember to test types with expectTypeOf and @ts-expect-error. Watch out for union complexity and recursion depth. And always validate data from outside your program.

typescripttemplate literal typestype systemgenericsvitest