Template literal types look like a party trick the first time you see them — string interpolation, but at the type level — until you hit the specific problem they solve: types that are strings, but not any string. An event name, a CSS custom property, a route path — these are all strings with structure, and template literal types let the compiler enforce that structure instead of trusting a comment.
Typed event names
A common pattern is deriving a set of DOM-style event names from a base list:
type BaseEvent = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<BaseEvent>}`;
// "onClick" | "onFocus" | "onBlur"
function on<E extends BaseEvent>(event: E, handler: () => void) {}
on("click", () => {}); // fine
on("clic", () => {}); // error — not in the unionThis is more valuable than it looks in a component library: instead of maintaining onClick, onFocus, onBlur as three separately-typed, hand-written props, you derive the prop names from one source list and they can't drift apart.
Extracting parts of a string with infer
Combined with conditional types, template literals can pull structured pieces out of a string type — the same way infer pulls a type out of Promise<T>:
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractRouteParams<Rest>]: string }
: T extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
type Params = ExtractRouteParams<"/users/:userId/posts/:postId">;
// { userId: string; postId: string }That's a real pattern lifted from typed-router libraries: define the route as a plain string, and the params object is derived from it — no separate params type to keep in sync by hand.
Type-safe object paths
The other place this earns its keep is dotted-path access — the kind of string you'd pass to a form library or a get(obj, "user.address.city") helper:
type PathsOf<T, Prefix extends string = ""> = {
[K in keyof T & string]: T[K] extends object
? `${Prefix}${K}` | PathsOf<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string];
interface FormValues {
user: { name: string; address: { city: string } };
}
type FormPath = PathsOf<FormValues>;
// "user" | "user.name" | "user.address" | "user.address.city"
function getValue<P extends FormPath>(path: P): unknown { /* ... */ }
getValue("user.address.city"); // valid
getValue("user.zip"); // error — not a real pathTypo a field name in a form binding and it's a compile error instead of a silently-undefined value in production.
Knowing when to stop
The PathsOf type above is genuinely useful, but push it one level further — say, adding array index support or making it work with recursive, self-referencing types — and the error messages TypeScript produces become nearly unreadable walls of nested conditional expansion. That's the signal to back off. If a template literal type needs a comment explaining what it does, or if changing an unrelated part of the codebase suddenly makes tsc slow to a crawl on this file, a runtime validator checking the same constraint is often the better trade: weaker compile-time guarantees, but a type signature a teammate can actually read, and an error a user can actually understand.