Template literal types in TypeScript: parsing during type checking and more

[2025-01-24] dev, typescript
(Ad, please don’t block)

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:

  • Static syntax checking for string literals
  • Transforming the casing of property names (e.g. from hyphen case to camel case)
  • Concisely specifying large string literal union types

Notation used in this blog post  

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);

The structure of this blog post  

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:

  • Syntax and basic ways of using template literal types
  • Utility types for string manipulation
  • Working with tuples
  • Working with objects

After that, we’ll look at practical examples and neat things that people have done with template literal types.

Syntax and basic ways of using template literal types  

The syntax  

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.

Concatenation is distributive  

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}`;

Extracting substrings  

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);

Constraining string values  

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.

Utility types for string manipulation  

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

Example: 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);

Example: 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);

Example: trimming string literal types  

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.

Working with tuples  

Joining a tuple of strings  

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:

  • If the tuple Rest is empty, the result is simply First.
  • Otherwise, we concatenate First with the separator Sep and the joined Rest.

Splitting a string  

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).

Example: converting a string literal to a string literal union  

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.

Splitting a string into code units  

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);

Working with objects  

Example: adding prefixes to property names  

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.
    • the intersection of the keys of Obj and all strings, or
    • the string keys of Obj.
  • Each loop iteration contributes one property to the output object:

    • The key of that property is specified via as: `$${Key}`
    • The value of that property comes ofter the colon: Obj[Key]

Practical examples  

Example: styling output to a terminal  

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'

Example: property paths  

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));
}

Example: changing property name prefixes  

This is a more complicated version of an earlier example:

  • The type JsonLd describes structured data in JSON-LD format.
  • We use 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.

Example: converting camel case to hyphen case  

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);

Example: converting hyphen case to camel case  

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);

Neat things people are doing with template literal types  

In this section, I have collected interesting things I have seen people do with template literal types.

Node.js: type for UUIDs  

@types/node uses the following type for UUIDs:

type UUID = `${string}-${string}-${string}-${string}-${string}`;

Parsing CLI arguments (Stefan Baumgartner)  

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

Smart result type of 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.

Typing routes in Angular (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.

Express route extractor (Dan Vanderkam)  

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

Tailwind color variations (Tomek Sułkowski)  

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

Arktype: defining types  

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"]
});

Conclusion and caveats  

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:

  • The error messages are usually not very good.
  • Type-level code that uses template literals can be difficult to understand.
  • Such code can slow down type checking – especially if it involves recursion.

Further reading  

Sources of this blog post