Let’s be honest: you’ve probably used template literals in JavaScript to cobble together strings. You know, the good old backtick syntax like `Hello, ${name}`. It’s string interpolation. It’s fine. Useful, even.

Well, TypeScript’s template literal types are that, but for your type system. They take the concept of “building a string” and elevate it to a meta-level, letting you construct and deconstruct type names themselves using the same familiar syntax. It’s one of those features that feels like a parlor trick at first but quickly becomes an indispensable tool for modeling everything from API routes to CSS-in-JS libraries.

The core syntax is laughably simple. You use a backtick string at the type level and interpolate other types inside it using ${}. The only rule is that whatever you slot into those placeholders must be of type string, number, bigint, boolean, null, or undefined. In other words, types that can meaningfully be represented as a string.

type World = "world";
type Greeting = `Hello ${World}`;
// type Greeting = "Hello world"

It’s not magic. It’s just a distributive type operation. The type system takes the union of types inside the ${} and creates a new union by smushing them together with the surrounding string parts.

How Distribution Works in Unions

This is where the real power, and the initial head-scratching, comes from. If you interpolate a union type, the template literal type will distribute over each member of that union, creating a new union of every possible combination. It’s like a Cartesian product for your types.

type Event = "click" | "hover" | "drag";
type Element = "div" | "span" | "input";

type EventHandlerName = `on${Capitalize<Event>}${Element}`;
// type EventHandlerName =
//   | "onClickdiv" | "onClickspan" | "onClickinput"
//   | "onHoverdiv" | "onHoverspan" | "onHoverinput"
//   | "onDragdiv"  | "onDragspan"  | "onDraginput"

Whoa, hold on. That’s… not what we wanted. We got "onClickdiv" instead of "onClickDiv". The designers gave us the distributive power but left us to handle the capitalization ourselves. This is one of those questionable choices—surely they could have anticipated this would be the primary use case? Thankfully, they also gave us the intrinsic string manipulation types to clean up our mess.

The Intrinsic String Helpers: Uppercase, Capitalize, et al.

TypeScript provides a set of built-in types to perform basic string operations at the type level: Uppercase, Lowercase, Capitalize, and Uncapitalize. These are “intrinsic,” meaning they’re implemented directly by the TypeScript compiler itself; you can’t build them from other type operations. They exist precisely for moments like this.

Let’s fix our previous example.

type Event = "click" | "hover" | "drag";
type Element = "div" | "span" | "input";

// Better: Capitalize the event and the element
type EventHandlerName = `on${Capitalize<Event>}${Capitalize<Element>}`;
// type EventHandlerName =
//   | "onClickDiv" | "onClickSpan" | "onClickInput"
//   | "onHoverDiv" | "onHoverSpan" | "onHoverInput"
//   | "onDragDiv"  | "onDragSpan"  | "onDragInput"

Much better. Now we have a perfectly generated union of all possible event handler prop names. This is incredibly powerful for generating massive, fully type-safe type definitions from a small set of source types.

Interpolating Non-String Types

I said the interpolated type must be string, number, etc. But what happens if you use a number? Exactly what you’d hope: it gets converted to its string representation.

type Version = `v${1 | 2}.${0}.${0}`;
// type Version = "v1.0.0" | "v2.0.0"

type MatrixIndex = `row-${1 | 2}-col-${1 | 2 | 3}`;
// type MatrixIndex = "row-1-col-1" | "row-1-col-2" | ... etc

This is fantastic for generating literal type unions for version numbers, matrix coordinates, or any other scenario where a number is part of an identifier.

The Pitfall: Garbage In, Garbage Out

The most common mistake is forgetting that distribution happens with unions. If your source type is string (the wide, non-literal type), your template literal type becomes… string. Because it can’t infer any specific literals to combine.

type Names = "alice" | "bob";
type Greeting = `Hello ${Names}`; // This is a specific union: "Hello alice" | "Hello bob"

type DynamicName = string;
type DynamicGreeting = `Hello ${DynamicName}`; // This is just `string`. Useful? No.

You can’t create a specific type from a non-specific one. The template literal type doesn’t create a pattern; it performs a concrete, set-based operation. If the set is “all strings,” the output is also “all strings.”

Best Practice: Inference with infer and Pattern Matching

The real genius of template literal types is that they work bidirectionally. You can use them not just to create types, but to deconstruct them using pattern matching in conditional types. This is how you build parsers and validators at the type level.

// Can we extract the event and element from our handler name?
type ExtractEventAndElement<T> =
    T extends `on${infer Event}${infer Element}`
        ? { event: Uncapitalize<Event>, element: Uncapitalize<Element> }
        : never;

// Let's test it
type Test = ExtractEventAndElement<"onClickDiv">;
// type Test = { event: "click"; element: "div"; }

We use infer to capture the parts of the string that match the positions of ${}. Notice we used Uncapitalize on the inferred types. Why? Because we inferred Event as "Click" and Element as "Div". We need to reverse the capitalization we applied during creation to get back to the original literal types. It’s a bit of bookkeeping, but it’s what makes the whole system coherent and powerful. This ability to deconstruct types is what makes features like type-safe routing and theming libraries possible without a mountain of boilerplate.