Template literal types shipped in TypeScript 4.1, and they are the feature that finally let the type system describe strings the way regular expressions describe them — not just "this is a string" but "this is a string of this exact shape." Once you can express shape, you can derive one string type from another, and that's where the interesting patterns start: event names generated from a list of event kinds, getter method names generated from a list of properties, route paths generated from route segments. None of it is magic. It's the same template literal syntax you already use at runtime, moved into the type position.
The basic syntax
A template literal type looks exactly like a JavaScript template literal, except the interpolated part is a type instead of a value. Wherever you'd write ${expr} in a runtime string, you write ${T} in a type, and T gets substituted with each member of whatever union it resolves to:
type Greeting = `hello ${string}`;
// matches any string starting with "hello "
type Size = "small" | "medium" | "large";
type SizeClass = `size-${Size}`;
// "size-small" | "size-medium" | "size-large"
The important part is that when you put a union type inside the interpolation, TypeScript doesn't produce one wide string type — it distributes over the union and produces a new union, one member per combination. That's the whole trick behind everything else in this post: template literal types plus unions give you a cross-product.
Combining unions for a cross product
This is where template literal types earn their keep. Say you have a fixed set of DOM-style event kinds and you want to derive the corresponding handler prop names — onClick, onHover, and so on — the way React's JSX typings do. You need two things: a way to capitalize the event name, and a template literal type that prepends on:
type EventKind = "click" | "hover" | "focus";
type HandlerName = `on${Capitalize}`;
// "onClick" | "onHover" | "onFocus"
type Handlers = {
[K in HandlerName]?: (event: Event) => void;
};
// { onClick?: (e: Event) => void; onHover?: (e: Event) => void; onFocus?: (e: Event) => void }
If EventKind had two more members added to it tomorrow, HandlerName and Handlers pick up the new handler names automatically, with no edits anywhere else. That's the actual payoff: the string shape is derived from a single source of truth instead of retyped by hand in three places and drifting out of sync in the fourth.
The intrinsic string manipulation types
TypeScript ships four built-in generic types specifically for use inside template literal types: Uppercase<T>, Lowercase<T>, Capitalize<T>, and Uncapitalize<T>. They're compiler intrinsics — there's no TypeScript source you can look up for their implementation, they're implemented directly in the type checker — but they behave exactly like you'd expect from their names, and they only affect the type, not the runtime value:
type Cmd = "start" | "stop" | "restart";
type Loud = Uppercase; // "START" | "STOP" | "RESTART"
type Quiet = Lowercase<"GET">; // "get"
type Titled = Capitalize; // "Start" | "Stop" | "Restart"
type Plain = Uncapitalize<"Start">; // "start"
Each of the four distributes over unions the same way template literal interpolation does, which is why Capitalize<Cmd> above produces a three-member union rather than a single string. That distribution is what makes them useful glued together with template literal types — Capitalize alone just transforms a string type; combined with on${...} it builds a whole family of names in one line.
Uppercase, Lowercase, Capitalize, and Uncapitalize change what the compiler believes a string's type looks like. They do not run any code and have no effect on the actual runtime string — you still need .toUpperCase() or your own capitalize helper if you want the value itself transformed, and it's on you to make sure the runtime logic and the type actually agree.
Key remapping with mapped types
TypeScript 4.1 added the same release that gave us template literal types also gave mapped types an as clause for remapping keys. Put together, you can take an existing object type and mechanically generate a new interface where every key is derived from the old one — the canonical example being turning plain properties into getter method names:
interface Person {
name: string;
age: number;
}
type Getters = {
[K in keyof T as `get${Capitalize}`]: () => T[K];
};
type PersonGetters = Getters;
// { getName: () => string; getAge: () => number }
The K & string intersection is not decoration — keyof T can include number or symbol keys, and Capitalize only accepts string, so the intersection narrows K down to the string keys before it's handed to Capitalize. Leave it off and the compiler will reject the mapped type the moment T could have a non-string key, even if in practice every type you pass in only has string properties.
Getters<Person> describes the shape a class or object literal must satisfy — it does not write the getName() method body for you. You still implement the class by hand (or generate it with a code-gen step); the mapped type's job is making sure the implementation and the derived interface can't quietly drift apart.
Wrapping up
Template literal types turn TypeScript's string types from an all-or-nothing "it's a string" into something with actual shape, and shape is what lets you derive one type from another instead of maintaining parallel lists by hand. The pattern in this post is always the same: take a union you already have, run it through Capitalize/Uppercase/Lowercase/Uncapitalize if the casing needs to change, splice it into a template literal type to get the cross-product of strings you actually want, and — if you're deriving object keys rather than a bare string union — feed that into a mapped type's as clause. Once the source union changes, everything downstream changes with it, which is the entire point of putting the type system to work instead of retyping the same names in four places.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.