Lesson 16 / 23
Template Literal Types
Expressing string formats as types, the cross product over unions, built-in string transforms, parsing a format with infer, open-ended patterns, and a literal type dissolving inside string.
Contents
The mapped types lesson used the notation `read${Capitalize<string & A>}` and
computed a field name. A template literal type combines string literal types to
produce new literal types.
Its notation is the same as JavaScript’s template literals, except what is combined is types, not values. This lesson covers how string formats are typed with this tool, its behavior over unions, and its limits.
Defining a Format
Measurement records are often referred to by a channel name: which sensor, in which unit. This name carries a format, and the format can be typed:
type Sensor = "temperature" | "pressure"; type Unit = "C" | "Pa"; type Channel = `${Sensor}.${Unit}`; const channels: Channel[] = ["temperature.C", "temperature.Pa", "pressure.C", "pressure.Pa"]; console.log(channels.length, channels[0]);
The output is 4 temperature.C. If the line const wrong: Channel = "temperature.%";
is added at the end of the file, after a blank line:
q1.ts(9,7): error TS2820: Type '"temperature.%"' is not assignable to type '"pressure.C" | "pressure.Pa" | "temperature.C" | "temperature.Pa"'. Did you mean '"temperature.C"'?
The diagnostic writes out the computation’s result explicitly: type Channel is a
union of four literal types. When every substituted type is a union, the result is the
cross product of those unions — two sensors and two units give four channels.
The suggestion at the end of the diagnostic is also a convenience the compiler offers for literal type unions: for a value that is likely a typo, it shows the nearest valid literal.
This type records that the string is not just any string, but carries a specific format. It serves the same purpose as the branded type from the Structural Type Compatibility lesson, but because the format can actually be defined, no type assertion is needed.
Built-in String Transforms
The compiler provides four string transforms built in: Uppercase, Lowercase,
Capitalize, and Uncapitalize.
type Sensor = "temperature" | "pressure"; type UppercaseSensor = Uppercase<Sensor>; type EventName = `measurement${Capitalize<Sensor>}Taken`; const uppercase: UppercaseSensor = "TEMPERATURE"; const eventName: EventName = "measurementPressureTaken"; console.log(uppercase, eventName);
The output is TEMPERATURE measurementPressureTaken. If the line
const wrongEvent: EventName = "measurementpressureTaken"; is added at the end of the
file, after a blank line:
q3.ts(10,7): error TS2820: Type '"measurementpressureTaken"' is not assignable to type '"measurementPressureTaken" | "measurementTemperatureTaken"'. Did you mean '"measurementPressureTaken"'?
These transforms are built into the compiler; they cannot be written at the type level.
Case conversion follows a fixed, locale-independent rule — so locale-specific rules,
such as Turkish’s distinction between dotless ı and dotted i, do not apply here.
This is one reason to stay in ASCII for channel and event names.
Building a Key Table
A template literal type can be the key source of a mapped type. The result is a table with a defined and complete format:
type Sensor = "temperature" | "pressure"; type Unit = "C" | "Pa"; type Channel = `${Sensor}.${Unit}`; type ChannelTable = { [K in Channel]: number; }; const lastValues: ChannelTable = { "temperature.C": 21.4, "temperature.Pa": 0, "pressure.C": 0, "pressure.Pa": 101325, }; console.log(lastValues["pressure.Pa"], Object.keys(lastValues).length);
The output is 101325 4.
The table’s keys were not counted by hand; when a third sensor is added to the Sensor
union, the table stays incomplete and the compiler gives a diagnostic. Expanding the
measurement model forces the data structures that use the model to be updated.
Parsing a Format with infer
Template literal types can also be used on the left side of a conditional type.
infer extracts the pieces of the format:
type SensorName<K> = K extends `${infer S}.${string}` ? S : never; type UnitName<K> = K extends `${string}.${infer U}` ? U : never; type Channel = "temperature.C" | "pressure.Pa"; declare const s: SensorName<Channel>; declare const u: UnitName<Channel>; const x: null = s; const y: null = u;
q5.ts(9,7): error TS2322: Type '"pressure" | "temperature"' is not assignable to type 'null'. Type '"pressure"' is not assignable to type 'null'. q5.ts(10,7): error TS2322: Type '"C" | "Pa"' is not assignable to type 'null'. Type '"C"' is not assignable to type 'null'.
Parsing worked together with distribution: each member of the Channel union was
matched separately, and the results were combined into a union.
This type can be used in the signature of a run-time parse:
type Sensor = "temperature" | "pressure"; type Unit = "C" | "Pa"; type Channel = `${Sensor}.${Unit}`; type SensorName<K extends Channel> = K extends `${infer S}.${string}` ? S : never; function sensorName<K extends Channel>(channel: K): SensorName<K> { return channel.split(".")[0] as SensorName<K>; } const result = sensorName("pressure.Pa"); console.log(result);
The output is pressure. If the line
const wrongAssign: "temperature" = sensorName("pressure.Pa"); is added at the end of
the file, after a blank line:
q6.ts(14,7): error TS2322: Type '"pressure"' is not assignable to type '"temperature"'.
The call sensorName("pressure.Pa") has type "pressure", not string. The return
type was computed from the literal type given at the call site.
Notice the as SensorName<K> in the body: the limit established in the previous lesson
applies here too, a conditional return type does not resolve inside the body. This is a
debt that accepts the body honors the promise made in the signature, and because it is
collected in a single line, it stays testable.
Open-Ended Patterns
The substituted type does not have to be a union; string can be written too. The
result is not a fixed set but a pattern type:
type MeasurementEvent = `measurement:${string}`; type WarningEvent = `warning:${string}`; type Occurrence = MeasurementEvent | WarningEvent; function route(occurrence: Occurrence): string { if (occurrence.startsWith("measurement:")) { return `logged: ${occurrence}`; } return `reported: ${occurrence}`; } console.log(route("measurement:received")); console.log(route("warning:threshold-exceeded"));
Output:
logged: measurement:received reported: warning:threshold-exceeded
The type `measurement:${string}` covers every string starting with
"measurement:". Because it is not a closed set, it cannot be enumerated one by one
like in the previous section; in exchange, the prefix requirement is preserved. If the
line const wrong: MeasurementEvent = "warning:received"; is added at the end of the
file, after a blank line:
u12.ts(15,7): error TS2322: Type '"warning:received"' is not assignable to type '`measurement:${string}`'.
This pattern is used for strings whose body is free but whose format is binding, such as event names or key prefixes. There is no need to keep a fixed list; the compiler reports it when the prefix is misspelled.
A Literal Type Dissolving Inside string
Open-ended patterns come with a trap. A literal type disappears when it is put in
the same union as string:
type Channel = "temperature.C" | string; declare const channel: Channel; const x: null = channel;
u8.ts(4,7): error TS2322: Type 'string' is not assignable to type 'null'.
The diagnostic writes string instead of Channel: since the "temperature.C"
member is already inside the string set, the union has reduced to string. The
result is a type carrying no constraint at all — while the way it is written gives the
impression of offering a suggestion list.
This is a mistake often made when trying to model “known values plus free text.” The right tool is a closed union or an open-ended pattern, used exactly where the constraint is actually wanted:
type Channel = `${"temperature" | "pressure"}.${"C" | "Pa"}`; function parseUnit(channel: Channel): string { return channel.split(".")[1]; } const channels: Channel[] = ["temperature.C", "pressure.Pa"]; console.log(channels.map(parseUnit).join(","));
The output is C,Pa. If the following two lines are added at the end of the file,
after a blank line:
const free: string = "temperature.C"; parseUnit(free);
u13.ts(11,11): error TS2345: Argument of type 'string' is not assignable to parameter of type '"pressure.C" | "pressure.Pa" | "temperature.C" | "temperature.Pa"'.
The literal types have not dissolved here: the type is still a closed four-value set,
and a free string is not accepted. Adding string to a literal type union is exactly
the operation that removes that guarantee.
The Union Size Limit
The cross product grows fast. Two three-member unions give nine results; every extra component multiplies the factor. The compiler has an upper limit:
type Letter = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j"; type Combination = `${Letter}${Letter}${Letter}${Letter}${Letter}`; declare const x: Combination; const y: null = x;
q9.ts(2,20): error TS2590: Expression produces a union type that is too complex to represent.
Ten letters combined five at a time is a hundred thousand literal types, and the compiler rejects it. The limit can change by version; the real lesson is that a template literal type is suitable when the format is closed, and unsuitable when it is open.
The combinatorial explosion concept from the Algorithms course shows up here in the type checker itself. For freely formatted strings, the right tool is not a literal type union but run-time validation and a branded type.
Summary
- A template literal type defines string formats at the type level by combining literal types; when the substituted types are unions, the result is the cross product of those unions.
Uppercase,Lowercase,Capitalize, andUncapitalizeare built into the compiler and work independently of locale.- When a template literal type is used as a mapped type’s key source, a table with a
defined and complete format is obtained; on the left side of a conditional type,
inferextracts format pieces and the result distributes over unions. - When
stringis substituted in, the type becomes not a closed set but an open-ended pattern type; the prefix requirement is preserved, values cannot be enumerated. - A literal type dissolves when placed in the same union as
string, and the union reduces tostring; “known values plus free text” cannot be modeled this way. - The cross product grows fast, and past a certain size the compiler gives the
TS2590diagnostic; closed literal type sets can only be built at a limited size.
Next Step
Most of the transforms written by hand in this topic — a read-only version, an optional version, filtering a member out of a union — already exist as ready-made types in the standard library. The next lesson introduces these utility types, shows which mechanism each is built on, and rewrites the measurement model’s derivatives using them.
To keep your progress and take notes, Log in
My notes
Log in to take notes.