In this blog post, we take a closer look at template literal types in TypeScript: While their syntax is similar to JavaScript’s template literals, they operate at the type level. Their use cases include:
For showing inferred types in source code, I use the npm package ts-expect
– e.g.:
// Types of values
expectType<string>('abc');
expectType<number>(123);
// Equality of types
type Pair<T> = [T, T];
expectType<TypeEqual<
Pair<string>, [string,string]
>>(true);
First, we’ll learn how template literal types work via small toy examples. We’ll write type-level code that looks similar to JavaScript code. These are the topics we’ll cover:
After that, we’ll look at practical examples and neat things that people have done with template literal types.
The syntax of template literal types is inspired by JavaScript’s template literals. They also let us concatenate values, optionally interspersed with static string fragments:
type Song<Num extends number, Bev extends string> = // (A)
`${Num} bottles of ${Bev}`
;
expectType<TypeEqual<
Song<99, 'juice'>, // (B)
'99 bottles of juice'
>>(true);
Explanations:
In line (A), we define the generic type Song
. It is similar to a function in JavaScript but operates at the type level. The extends
keyword is used to specify the types that Num
and Bev
must be assignable to.
In line (B), we apply Song
to two arguments: a number literal type and a string literal type.
If we insert a string literal union type into a template literal type, the latter is applied to each member of the former:
type Modules = 'fs' | 'os' | 'path';
type Prefixed = `node:${Modules}`;
expectType<TypeEqual<
Prefixed, 'node:fs' | 'node:os' | 'node:path'
>>(true);
If we insert more than one string literal union type, then we get the cartesian product (all possible combinations are used):
type Words = `${ 'd' | 'l' }${ 'i' | 'o' }ve`;
expectType<TypeEqual<
Words, 'dive' | 'dove' | 'live' | 'love'
>>(true);
That enables us to concisely specify large unions. We’ll see a practical example later on.
Anders Hejlsberg warns: “Beware that the cross product distribution of union types can quickly escalate into very large and costly types. Also note that union types are limited to less than 100,000 constituents, and the following will cause an error:”
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
// @ts-expect-error: Expression produces a union type
// that is too complex to represent.
type Zip = `${Digit}${Digit}${Digit}${Digit}${Digit}`;
If we use the infer
operator inside a template literal, we can extract parts of strings:
type ParseSemver<Str extends string> =
Str extends `${infer Major}.${infer Minor}.${infer Patch}`
? [ Major, Minor, Patch ]
: never
;
expectType<TypeEqual<
ParseSemver<'1.2.3'>, ['1', '2', '3']
>>(true);
So far, we have inserted literal types and union types into template literals, but we can also insert other types – as long as TypeScript can parse them inside strings. That lets us constrain string values:
type Version = `v${number}.${number}`;
// @ts-expect-error: Type '""' is not assignable to
// type '`v${number}.${number}`'.
const version0: Version = '';
const version1: Version = 'v1.0'; // OK
// @ts-expect-error: Type '"v2.zero"' is not assignable to
// type '`v${number}.${number}`'.
const version2: Version = 'v2.zero';
These are the supported types:
const undefinedValue: `${undefined}` = 'undefined';
const nullValue: `${null}` = 'null';
const booleanValue: `${boolean}` = 'true';
const numberValue: `${number}` = '123';
const bigintValue: `${bigint}` = '123';
const stringValue: `${string}` = 'abc';
// @ts-expect-error: Type 'symbol' is not assignable to type
// 'string | number | bigint | boolean | null | undefined'.
const symbolValue: `${symbol}` = 'symbol';
Note that undefined
is the type with the single value undefined
. null
is similar.
TypeScript has four built-in string manipulation types (documentation):
Uppercase<StringLiteralType>
expectType<TypeEqual<
Uppercase<'hello'>, 'HELLO'
>>(true);
Lowercase<StringLiteralType>
expectType<TypeEqual<
Lowercase<'HELLO'>, 'hello'
>>(true);
Capitalize<StringLiteralType>
expectType<TypeEqual<
Capitalize<'hello'>, 'Hello'
>>(true);
Uncapitalize<StringLiteralType>
expectType<TypeEqual<
Uncapitalize<'HELLO'>, 'hELLO'
>>(true);
TypeScript (as of version 5.7) uses the following JavaScript methods to make the changes – which are not locale aware (source – see “technical details” at the end):
str.toUpperCase() // Uppercase
str.toLowerCase() // Lowercase
str.charAt(0).toUpperCase() + str.slice(1) // Capitalize
str.charAt(0).toLowerCase() + str.slice(1) // Uncapitalize
isUppercase
We can use Uppercase
to define a generic type IsUppercase
:
type IsUppercase<Str extends string> = Str extends Uppercase<Str>
? true
: false;
expectType<TypeEqual<
IsUppercase<'SUNSHINE'>, true
>>(true);
expectType<TypeEqual<
IsUppercase<'SUNSHINe'>, false
>>(true);
ToString
ToString
uses normal template literal type interpolation to convert primitive literal types to string literal types:
type ToString<
T extends string | number | bigint | boolean | null | undefined
> = `${T}`
;
expectType<TypeEqual<
ToString<'abc' | 123 | -456n | false | null | undefined>,
'abc' | '123' | '-456' | 'false' | 'null' | 'undefined'
>>(true);
To trim a string literal type, we recursively remove all spaces from its start and its end:
type TrimStart<Str extends string> =
Str extends ` ${infer Rest}`
? TrimStart<Rest> // (A)
: Str
type TrimEnd<Str extends string> =
Str extends `${infer Rest} `
? TrimEnd<Rest> // (B)
: Str
;
type Trim<Str extends string> = TrimStart<TrimEnd<Str>>;
expectType<TypeEqual<
TrimStart<' text '>,
'text '
>>(true);
expectType<TypeEqual<
TrimEnd<' text '>,
' text'
>>(true);
expectType<TypeEqual<
Trim<' text '>,
'text'
>>(true);
Note the self-recursive invocations in line A and line B.
Acknowledgement: This example was inspired by code by Mike Ryan.
type Join<Strs extends string[], Sep extends string = ','> =
Strs extends [
infer First extends string,
...infer Rest extends string[]
]
? Rest['length'] extends 0
? First
: `${First}${Sep}${Join<Rest, Sep>}`
: ''
;
expectType<TypeEqual<
Join<['Hello', 'How', 'Are', 'You'], ' '>,
'Hello How Are You'
>>(true);
Once again, we are programming at a type level in a functional style.
In the second line, we take the tuple Strs
apart (think destructuring):
First
is the first element (a string).Rest
is the remaining elements (a tuple).Next:
Rest
is empty, the result is simply First
.First
with the separator Sep
and the joined Rest
.type Split<Str extends string, Sep extends string> =
Str extends `${infer First}${Sep}${infer Rest}`
? [First, ...Split<Rest, Sep>] // (A)
: [Str] // (B)
;
expectType<TypeEqual<
Split<'How | are|you', '|'>,
['How ', ' are', 'you']
>>(true);
We use infer
inside a template literal to determine the prefix First
before a separator Sep
and then recursively invoke Split
(line A).
If no separator is found, the result is a tuple with the whole Str
(line B).
We can use the previously defined Split
and Trim
to convert a string literal to a string literal union:
type StringToUnion<Str extends string> =
Trim<TupleToUnion<Split<Str, '|'>>>;
type TupleToUnion<Tup extends readonly unknown[]> = Tup[number];
expectType<TypeEqual<
StringToUnion<'A | B | C'>,
'A' | 'C' | 'B'
>>(true);
TupleToUnion
treats the tuple Tup
as a map from numbers (indices) to values: Tup[number]
means “give me all the values” (the range of Tup
).
Note how the application of Trim
is distributed and applied to each member of the union.
If we infer without a fixed separator, then the First
inferred string is always a single code unit (a “JavaScript character”, not a Unicode code point which comprises one or two code units):
type SplitCodeUnits<Str extends string> =
Str extends `${infer First}${infer Rest}`
// `First` is not empty
? [First, ...SplitCodeUnits<Rest>]
// `First` (and therefore `Str`) is empty
: []
;
expectType<TypeEqual<
SplitCodeUnits<'rainbow'>,
['r', 'a', 'i', 'n', 'b', 'o', 'w']
>>(true);
expectType<TypeEqual<
SplitCodeUnits<''>,
[]
>>(true);
Below, we are using a mapped type to convert an object type Obj
to a new object type, where each property key starts with a dollar sign ($
):
type PrependDollarSign<Obj> = {
[Key in (keyof Obj & string) as `$${Key}`]: Obj[Key]
};
type Items = {
count: number,
item: string,
[Symbol.toStringTag]: string,
};
expectType<TypeEqual<
PrependDollarSign<Items>,
{
$count: number,
$item: string,
// Omitted: [Symbol.toStringTag]
}
>>(true);
How does this code work? We assemble an output object like this:
The result of PrependDollarSign
is computed by a loop. In each iteration, the loop variable Key
is assigned to one element of the following union of strings:
keyof Obj & string
, i.e.Obj
and all strings, orObj
.Each loop iteration contributes one property to the output object:
as
: `$${Key}`
Obj[Key]
In Node.js, we can use util.styleText()
to log styled text to the console:
console.log(
util.styleText(['bold', 'underline', 'red'], 'Hello!')
);
To get a list of all possible style values, evaluate this expression in the Node.js REPL:
Object.keys(util.inspect.colors)
Below, we define a function styleText()
that uses a single string to specify multiple styles and statically checks that that string has the proper format:
const styles = [
'bold',
'italic',
'underline',
'red',
'green',
'blue',
// ...
] as const; // `as const` enables us to derive a type
type StyleUnion = TupleToUnion<typeof styles>;
type TupleToUnion<Tup extends readonly unknown[]> = Tup[number];
type StyleTextFormat =
| `${StyleUnion}`
| `${StyleUnion}+${StyleUnion}`
| `${StyleUnion}+${StyleUnion}+${StyleUnion}`
;
function styleText(format: StyleTextFormat, text: string): string {
return util.styleText(format.split('+'), text);
}
styleText('bold+underline+red', 'Hello!'); // OK
// @ts-expect-error: Argument of type '"bol+underline+red"' is not
// assignable to parameter of type 'StyleTextFormat'.
styleText('bol+underline+red', 'Hello!'); // typo: 'bol'
The following example is based on code by Anders Hejlsberg:
type PropType<T, Path extends string> =
Path extends keyof T
// `Path` is already a key of `T`
? T[Path]
// Otherwise: extract first dot-separated key
: Path extends `${infer First}.${infer Rest}`
? First extends keyof T
// Use key `First` and compute PropType for result
? PropType<T[First], Rest>
: unknown
: unknown;
function getPropValue
<T, P extends string>
(value: T, path: P): PropType<T, P>
{
// Not implemented yet...
return null as PropType<T, P>;
}
const obj = { a: { b: ['x', 'y']}} as const;
expectType<
{ readonly b: readonly ['x', 'y'] }
>(getPropValue(obj, 'a'));
expectType<
readonly ['x', 'y']
>(getPropValue(obj, 'a.b'));
expectType<
'y'
>(getPropValue(obj, 'a.b.1'));
expectType<
unknown
>(getPropValue(obj, 'a.b.p'));
function myFunc(str: string) {
// If the second argument is not a literal,
// we can’t infer a return type.
expectType<
unknown
>(getPropValue(obj, str));
}
This is a more complicated version of an earlier example:
JsonLd
describes structured data in JSON-LD format.PropKeysAtToUnderscore
to convert the type JsonLd
into a type with keys that we don’t need to quote.type PropKeysAtToUnderscore<Obj> = {
[Key in keyof Obj as AtToUnderscore<Key>]: Obj[Key];
};
type AtToUnderscore<Key> =
// Remove prefix '@', add prefix '_'
Key extends `@${infer Rest}` ? `_${Rest}` : Key // (A)
;
type JsonLd = {
'@context': string,
'@type': string,
datePublished: string,
};
expectType<TypeEqual<
PropKeysAtToUnderscore<JsonLd>,
{
_context: string,
_type: string,
datePublished: string,
}
>>(true);
Note that in line A, we only the change key if it is a string that starts with an at symbol. Other keys (including symbols) are not changed.
We could constrain the type of Key
like this:
AtToUnderscore<Key extends string>
But then we couldn’t use AtToUnderscore
for symbols and would have to filter values in some manner before passing them to this utility type.
We can use template literal types to convert a string in camel case (JavaScript) to one in hyphen case (CSS).
Let’s first convert a string to a tuple of strings:
type SplitCamelCase<
Str extends string,
Word extends string = '',
Words extends string[] = []
> = Str extends `${infer Char}${infer Rest}`
? IsUppercase<Char> extends true
// `Word` is empty if initial `Str` starts with capital letter
? SplitCamelCase<Rest, Char, Append<Words, Word>>
: SplitCamelCase<Rest, `${Word}${Char}`, Words>
// We have reached the end of `Str`:
// `Word` is only empty if initial `Str` was empty.
: [...Words, Word]
;
type IsUppercase<Str extends string> = Str extends Uppercase<Str>
? true
: false
;
// Only append `Str` to `Arr` if `Str` isn’t empty
type Append<Arr extends string[], Str extends string> =
Str extends ''
? Arr
: [...Arr, Str]
;
expectType<TypeEqual<
SplitCamelCase<'howAreYou'>,
['how', 'Are', 'You']
>>(true);
expectType<TypeEqual<
SplitCamelCase<'PascalCase'>,
['Pascal', 'Case']
>>(true);
expectType<TypeEqual<
SplitCamelCase<'CAPS'>,
['C', 'A', 'P', 'S']
>>(true);
To understand how this works, consider the roles of the parameters:
Str
is the actual parameter and the only parameter that doesn’t have a default value. SplitCamelCase
uses recursion to iterate over the characters of Str
.Word
: While inside a word, we add characters to this parameter.Words
: Once a word is finished, it is added to this (initially empty) tuple. Once recursion is finished then Words
contains the result of SplitCamelCase
and is returned.The rest of the work involves lower-casing and joining the tuple elements, with hyphens as separators:
type ToHyphenCase<Str extends string> =
HyphenateWords<SplitCamelCase<Str>>
;
type HyphenateWords<Words extends string[]> =
Words extends [
infer First extends string,
...infer Rest extends string[]
]
? Rest['length'] extends 0
? Lowercase<First>
: `${Lowercase<First>}-${HyphenateWords<Rest>}`
: ''
;
expectType<TypeEqual<
ToHyphenCase<'howAreYou'>,
'how-are-you'
>>(true);
expectType<TypeEqual<
ToHyphenCase<'PascalCase'>,
'pascal-case'
>>(true);
Going the opposite way, from hyphen case to camel case, is easier because we have hyphens as separators. As a utility type, we use Split
from earlier in this post.
type ToLowerCamelCase<Str extends string> =
Uncapitalize<ToUpperCamelCase<Str>>
;
// Upper camel case (Pascal case) is easier to compute
type ToUpperCamelCase<Str extends string> =
camelizeWords<Split<Str, '-'>>
;
type camelizeWords<Words extends string[]> =
Words extends [
infer First extends string,
...infer Rest extends string[]
]
? Rest['length'] extends 0
? Capitalize<First>
: `${Capitalize<First>}${camelizeWords<Rest>}`
: ''
;
expectType<TypeEqual<
ToLowerCamelCase<'how-are-you'>,
'howAreYou'
>>(true);
expectType<TypeEqual<
ToUpperCamelCase<'how-are-you'>,
'HowAreYou'
>>(true);
In this section, I have collected interesting things I have seen people do with template literal types.
@types/node
uses the following type for UUIDs:
type UUID = `${string}-${string}-${string}-${string}-${string}`;
Given the following definitions for the parameters of a shell script (num
is an arbitrary name for a string parameter – not a type):
const opts = program
.option("-e, --episode <num>", "Download episode No. <num>")
.option("--keep", "Keeps temporary files")
.option("--ratio [ratio]", "Either 16:9, or a custom ratio")
.opts();
This type can be statically derived for opts
:
{
episode: string;
} & {
keep: boolean;
} & {
ratio: string | boolean;
}
More information: “The TypeScript converging point” by Stefan Baumgartner
document.querySelector()
(Mike Ryan) By parsing the argument of querySelector()
at compile time, we can derive nice types:
const a = querySelector('div.banner > a.call-to-action');
// HTMLAnchorElement
const b = querySelector('input, div');
// HTMLInputElement | HTMLDivElement
const c = querySelector('circle[cx="150"]');
// SVGCircleElement
const d = querySelector('button#buy-now');
// HTMLButtonElement
const e = querySelector('section p:first-of-type');
// HTMLParagraphElement
More information: tweet by Mike Ryan.
const AppRoutes = routes(
{
path: '' as const,
},
{
path: 'book/:id' as const,
children: routes(
{
path: 'author/:id' as const,
},
),
},
);
// `AppRoutes` determines (statically!) what arguments can be
// passed to `buildPath()`:
const buildPath = createPathBuilder(AppRoutes);
buildPath(); // OK
buildPath('book', 12); // OK
buildPath('book', '123', 'author', 976); // OK
buildPath('book', null);
// Error: argument not assignable to parameter
buildPath('fake', 'route');
// Error: argument not assignable to parameter
More information: tweet by Mike Ryan.
The first argument of handleGet()
determines the parameters of the callback:
handleGet(
'/posts/:postId/:commentId',
({postId, commentId}) => {
console.log(postId, commentId);
}
);
More information: tweet by Dan Vanderkam
Thanks to template literals being distributive, we can define TailwindColor
very concisely:
type BaseColor =
'gray' | 'red' | 'yellow' | 'green' |
'blue' | 'indigo' | 'purple' | 'pink';
type Variant =
50 | 100 | 200 | 300 | 400
500 | 600 | 700 | 800 | 900;
type TailwindColor = `${BaseColor}-${Variant}`;
More information: tweet by Tomek Sułkowski
Where the TypeScript library Zod uses chained method calls to define types, the ArkType library uses (mostly) string literals that are parsed via template literal types. I prefer Zod’s approach, but it’s amazing how much ArkType does with string literals:
const currentTsSyntax = type({
keyword: "null",
stringLiteral: "'TS'",
numberLiteral: "5",
bigintLiteral: "5n",
union: "string|number",
intersection: "boolean&true",
array: "Date[]",
grouping: "(0|1)[]",
objectLiteral: {
nested: "string",
"optional?": "number"
},
tuple: ["number", "number"]
});
It’s amazing what people are doing with template literal types: We now can statically check complex data in string literals or use them to derive types. However, doing so also comes with caveats:
as
clauses” by Anders Hejlsberg