Template Literal Types
Types built from string patterns: parsing and constructing strings at the type level, and the APIs this makes safe that were previously just documented.
Types built from string patterns: parsing and constructing strings at the type level, and the APIs this makes safe that were previously just documented.
An event system takes strings: "photo:uploaded",
"album:created". The type is string, so "photo:uploded"
compiles, the handler never fires, and the bug is a listener
that silently never runs.
Strings carry structure constantly — event names, routes, CSS
properties, environment keys — and describing that structure as
string throws all of it away. By the end of this lesson you
will build types from string patterns, parse strings at the type
level, and know where the technique stops being worth it.
type Entity = "photo" | "album";
type Action = "created" | "deleted";
type EventName = `${Entity}:${Action}`;
// "photo:created" | "photo:deleted" | "album:created" | "album:deleted"A template literal type uses the same syntax as a template
literal value, with types inside ${}. When the parts are
unions it produces every combination — four here.
function emit(event: EventName): void { ... }
emit("photo:created"); // fine
emit("photo:uploded"); // error, with the four valid names offeredAutocomplete works, which is often the larger benefit: the editor lists the combinations because it knows them.
The four built-in transformations:
Uppercase<"photo"> // "PHOTO"
Lowercase<"PHOTO"> // "photo"
Capitalize<"photo"> // "Photo"
Uncapitalize<"Photo"> // "photo"Combined with the key remapping from the previous lesson, that is how you generate derived names:
type Handlers<T extends string> = {
[K in T as `on${Capitalize<K>}`]: () => void;
};
type A = Handlers<"created" | "deleted">;
// { onCreated: () => void; onDeleted: () => void }The infer from the conditionals lesson matches inside a
pattern, which makes these types readable as well as
constructible:
type EntityOf<E> = E extends `${infer Entity}:${string}` ? Entity : never;
type A = EntityOf<"photo:created">; // "photo"Splitting on a separator, recursively:
type Split<S extends string, D extends string> =
S extends `${infer Head}${D}${infer Rest}`
? [Head, ...Split<Rest, D>]
: [S];
type A = Split<"a.b.c", ".">; // ["a", "b", "c"]Which gives the genuinely useful one — a type-safe path into a nested object:
type PathValue<T, P extends string> =
P extends `${infer K}.${infer Rest}`
? K extends keyof T
? PathValue<T[K], Rest>
: never
: P extends keyof T
? T[P]
: never;
type Config = { server: { host: string; port: number } };
type A = PathValue<Config, "server.host">; // string
type B = PathValue<Config, "server.hostt">; // neverfunction get<T, P extends string>(obj: T, path: P): PathValue<T, P> { ... }
get(config, "server.host"); // string
get(config, "server.hostt"); // never - the typo is visibleA string-based accessor that would otherwise return any now
returns the right type and rejects a misspelled path.
Three patterns that come up in real code.
Routes with parameters:
type Params<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? Param | Params<`/${Rest}`>
: Path extends `${string}:${infer Param}`
? Param
: never;
type A = Params<"/photos/:photoId/comments/:commentId">;
// "photoId" | "commentId"
function route<P extends string>(
path: P,
handler: (params: Record<Params<P>, string>) => void,
): void { ... }The handler's parameter object now has exactly the keys the path declares. Renaming a segment in the path is a compile error in the handler.
Prefixed keys, for environment variables or CSS variables:
type EnvKey = `PHOTO_${Uppercase<"apiUrl" | "logLevel">}`;
// "PHOTO_APIURL" | "PHOTO_LOGLEVEL"Constrained string formats, where the shape is simple:
type Hex = `#${string}`;
type Iso = `${number}-${number}-${number}`;Those are loose — #zz passes, and 1-2-3 is a valid Iso —
but they catch the common mistake of passing an unprefixed
colour or an entirely different format, at no runtime cost.
Bad — validating a format at the type level.
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
type IsEmail<S extends string> = S extends `${infer L}@${infer D}.${infer T}`
? L extends "" ? false
: D extends "" ? false
: T extends "" ? false
: true
: false;
type ValidEmail<S extends string> = IsEmail<S> extends true ? S : never;
function subscribe<S extends string>(email: ValidEmail<S>): void { ... }Good — a brand, validated at runtime.
type Email = string & { readonly __brand: "Email" };
function parseEmail(value: string): Email | null {
return value.includes("@") ? (value as Email) : null;
}
function subscribe(email: Email): void { ... }Only works on strings you wrote yourself.
Anything from a form, a database or a network response is
plain string, so the check does not apply where the data
actually comes from — it validates exactly the values least
likely to be wrong.
And a rejection prints the whole conditional chain, with nothing saying what was wrong with the address.
Works on real input.
The function that does the checking is the only way to obtain
an Email, so the type is proof a check happened.
And it can say what failed, in a sentence.
That is the boundary rule from the practice course: anything that must hold for runtime data needs a runtime check, and a template literal type cannot be one.
Three signs you have gone too far: the type is longer than the function it constrains; a wrong value produces an unreadable error; or the pattern only matches literals when your data is not literal.
A recurring annoyance: you want known values suggested but arbitrary strings allowed.
type Colour = "red" | "green" | string; // collapses to stringThe union absorbs the literals, and autocomplete offers nothing. The workaround:
type Colour = "red" | "green" | (string & {});string & {} is a type the checker treats as distinct from
string for absorption purposes while accepting the same
values. The literals survive, so autocomplete lists them, and
any string is still allowed.
It is a well-known trick rather than a designed feature. Use it where the ergonomics matter, and leave a comment, because it looks like a mistake.
BUILDING
type Event = `${Entity}:${Action}`;
unions multiply out into every combination
autocomplete lists them - often the bigger win
Uppercase / Lowercase / Capitalize / Uncapitalize
with key remapping: [K in T as `on${Capitalize<K>}`]
PARSING - infer inside the pattern
E extends `${infer A}:${string}` ? A : never
Split<S, D> recursively, into a tuple
PathValue<T, "a.b.c"> a typed accessor for a string path
a typo gives never, not any
WORTH IT FOR
route parameters -> the handler's params object is checked
prefixed keys -> env vars, CSS variables
loose formats -> `#${string}`, at zero runtime cost
NOT WORTH IT FOR
validating a format (email, URL, date)
it only applies to LITERAL strings, and your data is not literal
-> a brand + a runtime check, which works on real input
signs you went too far:
the type is longer than the function it constrains
a wrong value gives an unreadable error
the pattern only matches literals
LIMITS
combinations multiply; unions cap around 100,000
your editor slows long before the compiler refuses
AUTOCOMPLETE ON A LOOSE TYPE
"red" | "green" | string collapses to string
"red" | "green" | (string & {}) keeps the suggestions
a known trick, not a feature - leave a commentYou can now describe the structure inside a string, parse one at the type level, and make a string-based API checkable. The limit to hold on to is that it only reaches literal types — which means it improves the code you write and does nothing for the data you receive.
Next is Inference Deep Dive, which explains how the checker
arrives at a type in the first place. Everything in these three
lessons depends on inference behaving predictably, and knowing
its rules is what turns "why is this string and not "a"" into
a question you can answer.
Before you move on, write the event-name type for something in
your code that uses string constants, and replace the string
parameter with it. Then misspell one at a call site. The
difference between a silent no-op and a compile error is the
whole return on this technique.