DEV Community

loading...

I need to learn about TypeScript Template Literal Types

phenomnominal profile image Craig โ˜ ๏ธ๐Ÿ’€๐Ÿ‘ป ใƒปUpdated on ใƒป11 min read

It's Sunday in New Zealand and I don't want to get out of bed yet, so instead I'm going to listen to the new Menzingers album and learn about TypeScript Template Literal Types and write down what I found out as I go!

TypeScript string types:

Let's start with what I already know.

  • TypeScript has a string type. It covers all strings like const hello = "Hello World";, or const myName = `My name is ${name}`;.
  • You can also use a string literal type, such as type Hello = 'hello', which only matches that specific string.
  • You can use Union Types to be combine string literal types to be more precise about allowed string inputs. One good example is type Events = 'click' | 'doubleclick' | 'mousedown' | 'mouseup' | ...;

There are limitations to what TypeScript can know. Template strings will cause specific string types to expand out to the generic string type:

type A = 'a';
const a: A = `${'a'}`; // Argument of type 'string' is not assignable to parameter of type '"a"'.

In my experience, once you start typing stuff with specific strings you often end up duplicating a bunch of stuff too. Take the Events example from before:

type EventNames = 'click' | 'doubleclick' | 'mousedown' | 'mouseup';
type Element = {
    onClick(e: Event): void;
    onDoubleclick(e: Event): void;
    onMousedown(e: Event): void;
    onMouseup(e: Event): void;
    addEventListener(eventName: Event): void;
};

If I add a new event name to the EventNames type, I also have to change the Element type! That's probably fine most of the time, but it could cause issues.

Template Literal Types "basics"

(Spoiler: it's not basic at all!)

The PR where Template Literal Types looked cool when I first read it, and people got pretty excited! Then the TypeScript 4.1 Beta release notes came out, so I'm going to go through that first.

TypeScript 4.1 can concatenate strings in types using the same syntax from JavaScript:

type World = "world";

type Greeting = `hello ${World}`;
// same as
//   type Greeting = "hello world";

Using Union Types with concatenation enables combinations:

type VerticalAlignment = "top" | "middle" | "bottom";
type HorizontalAlignment = "left" | "center" | "right";
type Alignment = `${VerticalAlignment}-${HorizontalAlignment}`

declare function setAlignment(value: Alignment): void;

setAlignment("top-left"); // works!
setAlignment("middle-right"); // works!
setAlignment("top-middel"); // error!

There's also some fancy new mapping syntax which means I can change the Element type from before:

type EventNames = 'click' | 'doubleclick' | 'mousedown' | 'mouseup';
type Element = {
  [K in EventNames as `on${Capitalize<EventNames>}`]: (event: Event) => void;
} & {
  addEventListener(eventName: EventNames): void;
};
// same as 
// type Element = {
//  onClick(e: Event): void;
//  onDoubleclick(e: Event): void;
//  onMousedown(e: Event): void;
//  onMouseup(e: Event): void;
//  addEventListener(eventName: Event): void;
//};

That's pretty deep - it takes each of the strings in the EventNames type, passing it to a Capitalize type, and prepending on to each of them! Now if I add a new event name to the EventNames, the Element type will already reflect it!

These new features are obviously really powerful, and people have been making some amazing stuff, e.g.:

Grรฉgory Houllier collected some of these examples into one place, so I can see how they work by looking at the implementations!

Type-safe string dot notation:

Full implementation here.

What does it do?

const user = {
  projects: [
    { name: "Cool project!", contributors: 10 },
    { name: "Amazing project!", contributors: 12 },
  ]
};

get(user, "projects.0.contributors"); // <- I want this string to be type-safe!

I thought I was starting with an easy one, but it's still pretty complex! I simplified it a little bit (and probably broke it) but it'll be easier to figure out - my implementation is here.

How does it work?

I'll look at PathValue first.

type PathValue<T, P extends Path<T>> =
  P extends `${infer Key}.${infer Rest}`
  ? Key extends keyof T
    ? Rest extends Path<T[Key]>
      ? PathValue<T[Key], Rest>
      : never
    : never
  : P extends keyof T
    ? T[P]
    : never;

This is the code that take an object and a valid path to an object and returns the type of the value at the end of that path.

Conditional types are really hard to process, so I'm going to rewrite it how I think about it.

PathValue is a generic type so it's kind of like a type function, and it takes two things, T which could be anything, and P which has to be a valid Path for T. PathValue is also a conditional type - it has the shape A extends B ? C : D. In this case it has several nested conditionals! But each of the never bits is a condition that doesn't return a type, so I can simplify it down to the two valid condition paths. That looks something like this:

typefunction PathValue (T, P: Path<T>) {
  if (P extends `${infer Key}.${infer Rest}` && Key extends keyof T && Rest extends Path<T[Key]>) {
    return PathValue<T[Key], Rest>;
  }
  if (P extends keyof T) {
    return T[P];
  }
}

Since the first condition actually calls PathValue again, this is a recursive conditional type ๐Ÿคฏ๐Ÿคฏ๐Ÿคฏ. There are two base conditionals, one continues the recursion, the other ends it. Again I'll look at the "easier" one first.

if (P extends keyof T) {
  return T[P];
}

If P is just a string and it is an exact key of T, then return the type of that key. That means it is the end of the path and it can stop recursing.

The other condition is the magical bit.

if (P extends `${infer Key}.${infer Rest}` && Key extends keyof T && Rest extends Path<T[Key]>) {
  return PathValue<T[Key], Rest>;
}

Here's the fancy new bit:

P extends `${infer Key}.${infer Rest}`

This type says "check if the string contains a '.', and give me the two string literal types either side of the '.'". The equivalent JavaScript would be something like:

const [Key, Rest] = P.split('.');

The next part of the conditional takes the first string literal (Key) and makes sure it is a valid key of T:

Key extends keyof T

The last part of the conditional takes the second string literal (Rest) and makes that it is a valid Path for the type of T[Key].

So in the case of the example:

const user = {
  projects: [
    { name: "Cool project!", contributors: 10 },
    { name: "Amazing project!", contributors: 12 },
  ]
};
get(user, "projects.0.contributors");

If these conditions are all true, then the recursion continues and you go to the level in the object, and the next chunk of the dot-notation string.

That kind of makes sense and I now kind of understand P extends `${infer Key}.${infer Rest}` which seems pretty important. Next up is the Path type:

type Path<T, Key extends keyof T = keyof T> =
  Key extends string
  ? T[Key] extends Record<string, any>
    ? | `${Key}.${Path<T[Key], Exclude<keyof T[Key], keyof Array<any>>> & string}`
      | `${Key}.${Exclude<keyof T[Key], keyof Array<any>> & string}`
      | Key
    : never
  : never;

Again I'm going to write it out in a different way:

typefunction Path<T, Key extends keyof T = keyof T> {
  if (Key extends string && T[Key] extends Record<string, any>) {
    return `${Key}.${Path<T[Key], Exclude<keyof T[Key], keyof Array<any>>> & string}` | `${Key}.${Exclude<keyof T[Key], keyof Array<any>> & string}` | Key;
  }
}

This says that it Key is a string, and the type of the property on type T (Aka T[Key]) is a Record, then return some fancy union. There are three parts to the union:

`${Key}.${Path<T[Key], Exclude<keyof T[Key], keyof Array<any>>> & string}`

`${Key}.${Exclude<keyof T[Key], keyof Array<any>> & string}`

Key;

What does the Exclude<keyof T[Key], keyof Array<any>> bit mean? It uses TypeScript's built-in Exclude type which will remove any types in the second parameter from the first. In this specific case, it is going to remove any valid key for an Array (e.g. push, map, slice). I guess this also includes Object keys, but I'm not super sure how that works off the top of my head. This bit seems to me to be a bit of a nice to have, as it reduces the final set of possible paths a bit, but I can ignore it for now. That gives me:

`${Key}.${Path<T[Key], keyof T[Key]> & string}`

`${Key}.${keyof T[Key] & string}`

Key;

The & string bit is a little trick to reduce keyof T[Key] down to only being a string - I think because you can have symbol keys as well. So I can ignore that too:

So the final union is basically:

`${Key}.${Path<T[Key], keyof T[Key]>}` | `${Key}.${keyof T[Key]}` | Key;

This is another recursive type, where each level of recursion is concatenating the valid key paths like `${Key}.{Path}`, so you get `${Key}.{Path}` | ${Key}.{(`${Key}.{Path})`} | `${Key}.{(`${Key}.{Path})`}` ... etc. That handles all the deeply nested keys. That is combined with the very next layer of keys ${Key}.${keyof T[Key]}, and the current keys Key.

So at a high level there are two recursive types, one with recurses through the valid keys of an object and builds up the whole valid set, using Template Literal Types to concatenate the keys with a ".". The other type splits the concatenated keys and works out the type at each layer of the path. Makes sense I think? Pretty powerful stuff if you hide it away behind a nice API in a library.

Type-safe document.querySelector:

Full implementation here.

What does it do?

This one is a little different, as it doesn't validate that the string is a valid CSS selector (although I'm pretty sure that would be possible with these new types), but it does figure out the best type of the result of the query:

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

How does it work?

Let's look at some of the helper types first:

type Split<S extends string, D extends string> = S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>] : [S];
type TakeLast<V> = V extends [] ? never : V extends [string] ? V[0] : V extends [string, ...infer R] ? TakeLast<R> : never;
type TrimLeft<V extends string> = V extends ` ${infer R}` ? TrimLeft<R> : V;
type TrimRight<V extends string> = V extends `${infer R} ` ? TrimRight<R> : V;
type Trim<V extends string> = TrimLeft<TrimRight<V>>;

These are super clever, and seem like they could live alongside Capitalize etc in the base TypeScript types.

Split:
type Split<S extends string, D extends string> = S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>] : [S];

Again I'm going to rewrite it:

typefunction Split<S extends string, D extends string> {
    if (S extends `${infer T}${D}${infer U}`) {
        return [T, ...Split<U, D>];
    }
    return [S];
}

So there is another recursive type that takes an input string, and some splitter string D. If the input string contains the splitter string, the part of the string that comes before the splitter is put into an array, and then the second part of the string is passed to the Split type again. The result is splatted (...) which means that the final result will be a single flattened array of strings.

If the input string doesn't contain the splitter, then the whole string is returned. It's wrapped in an array so that the splat works.

TakeLast:
type TakeLast<V> = V extends [] ? never : V extends [string] ? V[0] : V extends [string, ...infer R] ? TakeLast<R> : never;

This one doesn't have anything to do with Template Types particularly but it's still interesting. Rewriting gives me something like this:

typefunction TakeLast<V> {
    if (V extends []) {
        return;
    }
    if (V extends [string]) {
        return V[0];
    }
    if (V extends [string, ...infer R]) {
        return TakeLast<R>;
    }
}

One change I might make to this would be to have type TakeLast<V> be typefunction TakeLast<V extends Array<string>>? That would limit the valid input types and possibly give an easier error message.

Three different paths through here:

1) If the array is empty, return nothing.
2) If the array contains one element, return it.
3) If the array contains more than one element, skip the first element and call TakeLast on the array of remaining elements.

TrimLeft/TrimRight/Trim:
type TrimLeft<V extends string> = V extends ` ${infer R}` ? TrimLeft<R> : V;
type TrimRight<V extends string> = V extends `${infer R} ` ? TrimRight<R> : V;
type Trim<V extends string> = TrimLeft<TrimRight<V>>;

More Template String types here:

Trim is pretty nice, it just calls TrimRight and then TrimLeft.

TrimLeft and TrimRight are basically the same so I'll just rewrite one of them:

typefunction TrimLeft<V extends string> {
    if (V extends ` ${infer R}`) {
        return TrimLeft<R>;
    }
    return V;
}

And I'll actually rewrite this again cause what it's actually doing is:

typefunction TrimLeft<V extends string> {
    if (V.startsWith(' ')) {
        return TrimLeft<R>;
    }
    return V;
}

This type recurses until it finds a string that doesn't start with a space. Makes sense, but still very cool to see it as a type.

TrimRight is pretty much identical but it does an endsWith instead.

StripModifiers

The last bit of Template Type magic I want to look at here is:

type StripModifier<V extends string, M extends string> = V extends `${infer L}${M}${infer A}` ? L : V;
type StripModifiers<V extends string> = StripModifier<StripModifier<StripModifier<StripModifier<V, '.'>, '#'>, '['>, ':'>;

That can be rewritten to be something like this:

typefunction StripModifier<V extends string, M extends string> {
    if (V.contains(M)) {
        const [left, right] = V.split(M);
        return left;
    }
    return V;
}

Then the StripModifiers type just uses the StripModifier type with each of the characters than can follow an element tag name in CSS:

typefunction StripModifiers<V extends string> {
    StripModifier(V, '.');
    StripModifier(V, '#');
    StripModifier(V, '[');
    StripModifier(V, ':');
}

The rest of this example uses these different types to split the CSS selector on relevant characters (' ', '>', and ','), and then select the relevant bit of the remaining selector and returning the correct type.

A lot of the heavy lifting is done by this type:

type ElementByName<V extends string> = 
    V extends keyof HTMLElementTagNameMap 
        ? HTMLElementTagNameMap[V] 
        : V extends keyof SVGElementTagNameMap 
        ? SVGElementTagNameMap[V] 
        : Element;

It maps from a string (such as 'a') to a type (such as HTMLAnchorElement), then checks SVG elements, before falling back to the default Element type.

What next?

The next examples get progressively more bonkers, so I'm not going to write down all my thinking about them - you should check them out though and see if you can see how they work. The JSON parser is probably the best mix of complex and readable.

From this I have a couple thoughts:

1) I should definitely use this for TSQuery
2) TypeScript is probably going to need new syntax for types soon because stuff like:

type ParseJsonObject<State extends string, Memo extends Record<string, any> = {}> =
  string extends State
    ? ParserError<"ParseJsonObject got generic string type">
    : EatWhitespace<State> extends `}${infer State}`
      ? [Memo, State]
      : EatWhitespace<State> extends `"${infer Key}"${infer State}`
        ? EatWhitespace<State> extends `:${infer State}`
          ? ParseJsonValue<State> extends [infer Value, `${infer State}`]
            ? EatWhitespace<State> extends `,${infer State}`
              ? ParseJsonObject<State, AddKeyValue<Memo, Key, Value>>
              : EatWhitespace<State> extends `}${infer State}`
                ? [AddKeyValue<Memo, Key, Value>, State]
                : ParserError<`ParseJsonObject received unexpected token: ${State}`>
            : ParserError<`ParseJsonValue returned unexpected value for: ${State}`>
          : ParserError<`ParseJsonObject received unexpected token: ${State}`>
        : ParserError<`ParseJsonObject received unexpected token: ${State}`>

is pretty tricky ๐Ÿ˜….

All in all that was pretty useful for me, I think I get how Template Literal Types work a bit now. I guess I'll see next time I try to use them.

Let me know if this was useful, it was a pretty unfiltered and unedited ๐Ÿ™ƒ

Discussion (9)

pic
Editor guide
Collapse
nandorojo profile image
Fernando Rojo

Seriously amazing article. I wish there was more legible content like this about advanced Typescript types. I thought your approach of using the typefunction made it especially easy to understand. Have you considered opening an RFC on the typefunction syntax? Itโ€™s a really interesting idea.

I would love to read more content like this, and encourage you to keep contributing.

Collapse
nielsboecker profile image
Niels Boecker

Hey Craig, these new TypeScript functionalities are blowing my mind and your article was amazing, really appreciated how you broke down the fairly hard to digest type definitions into smaller steps to help follow the thought process. :)

I have a nitpick, there seems to be a difference in your rewrite of the type-safe string dot notation example. Unlike the original code example, it will not accept top-level properties:

const user = {
  age: 12,
  projects: [
    { name: "Cool project!", contributors: 10 },
    { name: "Amazing project!", contributors: 12 },
  ]
} as const;

// This is not working :(
get(user, 'age');
Enter fullscreen mode Exit fullscreen mode
Collapse
phenomnominal profile image
Craig โ˜ ๏ธ๐Ÿ’€๐Ÿ‘ป Author

Nice catch! I'm not surprised that I broke something! Hopefully people don't use my version for real, as it was strictly for me trying to work it all out ๐Ÿ˜…

Collapse
nroboto profile image
Mike Nightingale

Thanks for the great article.

I was messing around with the string dot Path type, I think it can be made a little simpler by splitting it into two types

type Subpath<T, Key extends keyof T = keyof T> = (
  T[Key] extends Record<string, any> ?
    `${Key & string}.${Path<T[Key], Exclude<keyof T[Key], keyof any[]>>}`
  :
    never
);

type Path<T, Key extends keyof T = keyof T> = (
  Key extends string ?
    Key | Subpath<T, Key>
  :
    never
);
Enter fullscreen mode Exit fullscreen mode

Subpath gets the paths for properties of T, and Path stitches them all together into a single type.

PathType can also be made a lot more concise by using type intersection on Key and Rest

type PathValue<T, P extends Path<T>> = (
  P extends `${infer Key}.${infer Rest}` ?
    PathValue<T[Key & keyof T], Rest & Path<T[Key & keyof T]>>
  :
    T[P & keyof T]
);
Enter fullscreen mode Exit fullscreen mode

I'm not sure if this is easier to understand than the original though.

Here's the result.

Collapse
malcolmkee profile image
Malcolm Kee

One question about PathValue. With your implementation above, we get never with get(user, 'projects.0.name').

But it will get the correct type with one small change:

type Path<T, Key extends keyof T = keyof T> =
  (Key extends string
  ? T[Key] extends Record<string, any>
    ? | `${Key}.${Path<T[Key], Exclude<keyof T[Key], keyof Array<any>>> & string}`
      | `${Key}.${Exclude<keyof T[Key], keyof Array<any>> & string}`
+      | Key
-       | never
    : never
  : never)
Enter fullscreen mode Exit fullscreen mode

See here.

Collapse
captainyossarian profile image
yossarian

Awesome article!

with great power comes great responsibility :)))

Collapse
roszykdamian profile image
Damian Roszyk

Thanks for this great article!

Collapse
sirseanofloxley profile image
Sean Allin Newell

Wowza. Gonna have TS written in TS Types soon.

Collapse
llgcode profile image
llgcode • Edited

Thanks for this article, I learn a lot with your explanation. good album too :-)
I'll keep it in my favorite as a manual to understand template literal type