DEV Community

Kai Thorne
Kai Thorne

Posted on • Originally published at dev.to

TypeScript Template Literal Types: String Manipulation at the Type Level

TypeScript Template Literal Types: String Manipulation at the Type Level

You've got an event emitter in your codebase. The emit method takes an event name and a payload. Your IDE autocompletes the event name — great. But the payload type? That's up to you to remember.

// ❌ The problem: payload type depends on event name, but nothing enforces it
class EventBus {
  private listeners: Record<string, Function[]> = {};

  emit(event: string, payload: unknown): void {
    this.listeners[event]?.forEach(fn => fn(payload));
  }
}

const bus = new EventBus();
bus.emit("user:login", { userId: 123 });       // OK
bus.emit("user:login", "just a string");        // Also OK — but wrong!
bus.emit("nonexistent:event", { anything: true }); // No error at all
Enter fullscreen mode Exit fullscreen mode

Every event accepts unknown. Wrong payloads compile fine. Misspelled events compile fine. You only find out at runtime — or worse, in production.

Template literal types are the tool that lets you take this further. They let you manipulate string types at compile time, the same way template literals manipulate string values at runtime. And when you combine them with infer, mapped types, and intrinsic string types, you get compile-time guarantees that would otherwise require a separate validation layer.

What Are Template Literal Types?

If you've used JavaScript template literals, you already know the syntax. Template literal types look the same — but they operate on types instead of values:

type EventName = "click" | "focus" | "blur";
type EventHandler = `on${Capitalize<EventName>}`;
// Result: "onClick" | "onFocus" | "onBlur"
Enter fullscreen mode Exit fullscreen mode

Every union member in the placeholder gets expanded into a combination. If you have multiple placeholders, you get the Cartesian product of all possibilities.

The intrinsic string types — Capitalize, Uncapitalize, Uppercase, Lowercase — are built into TypeScript 4.1+ and work exclusively at the type level with template literal types.

Use Case 1: Typed Event Emitter

Let's solve the opening problem properly.

Start by defining your event map as a type:

interface EventMap {
  "user:login": { userId: number; sessionId: string };
  "user:logout": { userId: number };
  "cart:add": { productId: string; quantity: number };
  "cart:remove": { productId: string };
}
Enter fullscreen mode Exit fullscreen mode

Now derive the event names and their corresponding payloads:

type EventName = keyof EventMap & string;
// "user:login" | "user:logout" | "cart:add" | "cart:remove"

type PayloadFor<E extends EventName> = EventMap[E];
// { userId: number; sessionId: string } | ...
Enter fullscreen mode Exit fullscreen mode

With these, your typed event bus becomes:

class TypedEventBus {
  private listeners = new Map<string, Function[]>();

  on<E extends EventName>(
    event: E, 
    handler: (payload: PayloadFor<E>) => void
  ): void {
    const entries = this.listeners.get(event) ?? [];
    entries.push(handler);
    this.listeners.set(event, entries);
  }

  emit<E extends EventName>(event: E, payload: PayloadFor<E>): void {
    this.listeners.get(event)?.forEach(fn => fn(payload));
  }
}

const bus = new TypedEventBus();

bus.on("user:login", (payload) => {
  // ✅ payload.userId is number
  // ✅ payload.sessionId is string
  console.log(`User ${payload.userId} logged in`);
});

bus.emit("user:login", { userId: 1, sessionId: "abc" });
// ✅ Correct payload type enforced

bus.emit("user:login", { userId: 1 });
// ❌ Error: Property 'sessionId' is missing

bus.emit("user:logout", { userId: 1, sessionId: "abc" });
// ❌ Error: 'sessionId' doesn't exist in user:logout payload
Enter fullscreen mode Exit fullscreen mode

No template literal types needed yet — string literal unions plus generics handle this. Template literal types shine when you need to transform or parse string types.

Use Case 2: Event Name Transformations (Template Literals in Action)

Say your event map uses "camelCase" convention, but your DOM API uses "on" + PascalCase:

interface DOMEvents {
  click: MouseEvent;
  focus: FocusEvent;
  blur: FocusEvent;
  keydown: KeyboardEvent;
  keyup: KeyboardEvent;
}

type ElementListener<E extends keyof DOMEvents & string> = 
  `on${Capitalize<E>}`;
// "onClick" | "onFocus" | "onBlur" | "onKeydown" | "onKeyup"

type TypedElement = {
  [E in keyof DOMEvents as `on${Capitalize<E & string>}`]: 
    (event: DOMEvents[E]) => void;
};
Enter fullscreen mode Exit fullscreen mode

This is where template literal types + mapped type remapping (key renaming via as) really shine. Each event name gets transformed at the type level before it becomes a property key.

Use Case 3: Parsing Strings with infer

Template literal types let you extract parts of strings using the infer keyword in conditional types. This is like regex for types.

Extracting Route Parameters

type ExtractRouteParams<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractRouteParams<Rest>]: string }
    : T extends `${infer _Start}:${infer Param}`
      ? { [K in Param]: string }
      : {};

type UserRoute = ExtractRouteParams<"/users/:id/posts/:postId">;
// { id: string; postId: string }
Enter fullscreen mode Exit fullscreen mode

Here's what's happening:

  1. Match ${anything}:${paramName}/${rest} — captures a param before a /
  2. Match ${anything}:${paramName} — captures a trailing param
  3. No match → empty object

This recursively parses the path until all :param segments are extracted.

Parsing CSS Property Strings

type ParseCSSProperty<T extends string> =
  T extends `--${infer Name}`
    ? { custom: true; name: Name }
    : T extends `${infer Prop}-${infer Rest}`
      ? { custom: false; name: `${Prop}${Capitalize<Rest>}` }
      : { custom: false; name: T };

type A = ParseCSSProperty<"background-color">;
// { custom: false; name: "backgroundColor" }

type B = ParseCSSProperty<"--primary-color">;
// { custom: true; name: "primary-color" }

type C = ParseCSSProperty<"margin">;
// { custom: false; name: "margin" }
Enter fullscreen mode Exit fullscreen mode

The -${infer Rest} pattern split-then-Capitalize is exactly how libraries like React convert CSS property names to camelCase, but done entirely at the type level.

Extracting Promise Value Types

type UnwrapPromise<T> =
  T extends Promise<infer V> ? V : T;

type A = UnwrapPromise<Promise<string[]>>;
// string[]

type B = UnwrapPromise<number>;
// number
Enter fullscreen mode Exit fullscreen mode

Pair this with template literals to parse async patterns:

type ExtractAsyncType<T extends string> =
  T extends `${infer _}Async` ? `Promise<${_}>` : T;

type A = ExtractAsyncType<"fetchDataAsync">;
// "Promise<fetchData>"

type B = ExtractAsyncType<"syncOp">;
// "syncOp"
Enter fullscreen mode Exit fullscreen mode

Use Case 4: CSS Class Builder with Intrinsic Types

Here's a practical pattern — building typed CSS class name combinators:

type Size = "sm" | "md" | "lg";
type Color = "primary" | "secondary" | "danger";

type ButtonClass = `${Size}-${Color}`;
// "sm-primary" | "sm-secondary" | "sm-danger" | "md-primary" | ...

function button(config: { class: ButtonClass }): void {
  // render button
}

button({ class: "md-primary" });  // ✅
button({ class: "lg-danger" });   // ✅
button({ class: "xl-primary" });  // ❌ "xl" not a valid size
button({ class: "md-warning" });  // ❌ "warning" not a valid color
Enter fullscreen mode Exit fullscreen mode

9 valid combinations, exactly typed. No runtime validation needed.

Use Case 5: String Format Validation

Template literal types also work as compile-time validators for structured strings:

type HexDigit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" 
  | "8" | "9" | "a" | "b" | "c" | "d" | "e" | "f" | "A" | "B" 
  | "C" | "D" | "E" | "F";

type HexColor = `#${HexDigit}${HexDigit}${HexDigit}${HexDigit}${HexDigit}${HexDigit}`;

const valid: HexColor = "#ff5733";  // ✅
const invalid: HexColor = "#GGG000";  // ❌ "G" isn't a hex digit
Enter fullscreen mode Exit fullscreen mode

Or for semantic versioning:

type SemVer = `${number}.${number}.${number}`;

const v1: SemVer = "1.0.0";    // ✅
const v2: SemVer = "2.1.5";    // ✅
const v3: SemVer = "1.0";      // ❌ Missing patch version
Enter fullscreen mode Exit fullscreen mode

Note: TypeScript's number in template literal types matches any numeric literal string. This means "Infinity.0.0" and "NaN.1.0" would technically pass — template literal types check the shape, not the numerical validity. For stricter validation, pair with a branded type or a runtime check.

Use Case 6: Typed Query String Builder

Combine template literals with mapped types to derive valid query parameter combinations:

type QueryParams = {
  page?: number;
  limit?: number;
  sort?: "asc" | "desc";
  filter?: string;
};

type QueryString<T> = {
  [K in keyof T as K & string]: `${K & string}=${T[K]}`
};

type QueryEntries = QueryString<Required<QueryParams>>;
// {
//   page: "page=number";
//   limit: "limit=number";
//   sort: "sort=asc" | "sort=desc";
//   filter: "filter=string";
// }

type ValidQueryParam = QueryEntries[keyof QueryEntries];
// "page=number" | "limit=number" | "sort=asc" | "sort=desc" | "filter=string"
Enter fullscreen mode Exit fullscreen mode

This is the foundation for type-safe URL builder utilities.

When NOT to Use Template Literal Types

Template literal types are powerful, but they come with sharp edges:

🚨 Recursion depth limit. TypeScript has a ~50-level recursion limit on conditional types. Deeply nested template literal inference (like recursive route parsing with many parameters) will hit this limit. Keep your routes shallow or use union types for complex patterns.

// ❌ Will fail on deeply nested routes (>50 param segments)
type DeepRoute = ExtractRouteParams<"/a/:b/c/:d/e/:f/...">;
Enter fullscreen mode Exit fullscreen mode

🚨 Combinatorial explosion. Every union placeholder multiplies the result set. "${A}-${B}" where A has 5 members and B has 10 members produces 50 combinations. Three placeholders with 10 members each? 1000 combinations. TypeScript handles this poorly and your editor will slow to a crawl.

type A = "a1" | "a2" | ... | "a10";
type B = "b1" | "b2" | ... | "b10";
type C = "c1" | "c2" | ... | "c10";

type Explosion = `${A}-${B}-${C}`;
// 1000 combinations — TypeScript will struggle
Enter fullscreen mode Exit fullscreen mode

🚨 Template literal types are compile-time only. They have zero runtime effect. A valid template literal type doesn't mean the runtime value is valid — it only means the literal string in your source code matches the pattern. If your string comes from an API response or user input, you still need runtime validation.

function processColor(color: HexColor) {
  // color is type-checked at compile time
  // but if this value comes from an API response:
  // typeof color is still string at runtime
}

// Runtime safety requires an actual validator:
function isHexColor(s: string): s is HexColor {
  return /^#[0-9a-fA-F]{6}$/.test(s);
}
Enter fullscreen mode Exit fullscreen mode

🚨 infer splits on the first occurrence of the literal separator. When parsing with infer, TypeScript finds the leftmost occurrence of the literal text in the pattern and splits there. This can produce surprising results:

type GreedyParse<T> = T extends `${infer A}/${infer B}` ? [A, B] : never;

type Test = GreedyParse<"a/b/c">;
// ["a", "b/c"] — not ["a/b", "c"]!
Enter fullscreen mode Exit fullscreen mode

TypeScript finds the first / in the string and splits there: A gets everything before it, B gets everything after. If you need right-to-left splitting (matching the last /), you must recurse explicitly or restructure the pattern.

Summary

Pattern What it does Example
Basic substitution Expands placeholders across unions `on${Capitalize<E>}`
Key remapping Renames mapped type keys as \get${Capitalize}``
infer parsing Extracts parts of string types `${infer Param}:${infer Type}`
Recursive inference Parses structured strings Route parameter extraction
Intrinsic types Built-in string transformers Capitalize, Uppercase, etc.
Format validation Enforces struct patterns Hex color, semantic versioning

Template literal types bridge the gap between stringly-typed code and compile-time safety. They won't replace runtime validation — and they shouldn't. But for code that deals with string constants, naming conventions, and structured identifiers, they eliminate entire categories of bugs before your code ever runs.

The best part? They compose beautifully with mapped types (covered in my previous article on mapped types). Mapped types transform object types; template literal types transform string types. Together they handle most of what you'd otherwise reach for code generation to solve.


Have a real-world use case where template literal types saved you? Drop it in the comments. I'm always looking for patterns I haven't seen.

If you're shipping TypeScript and want to debug faster, check out my *10 ChatGPT Prompts for Debugging React & Next.js** — crafted templates that catch the patterns that trip up even senior devs. Or grab the full Developer Prompt Pack Bundle with 70+ prompts covering debugging, API building, React patterns, and code review.*

Top comments (0)