DEV Community

Craig β˜ οΈπŸ’€πŸ‘»
Craig β˜ οΈπŸ’€πŸ‘»

Posted on • Updated on

I need to learn about TypeScript Template Literal Types

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"'.
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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;
//};
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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];
  }
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

Here's the fancy new bit:

P extends `${infer Key}.${infer Rest}`
Enter fullscreen mode Exit fullscreen mode

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('.');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>>;
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>;
    }
}
Enter fullscreen mode Exit fullscreen mode

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>>;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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, '.'>, '#'>, '['>, ':'>;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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, ':');
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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}`>
Enter fullscreen mode Exit fullscreen mode

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 πŸ™ƒ

Top comments (14)

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
 
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
 
seanblonien profile image
Sean Blonien • Edited

This is great stuff!

I have been using your improved types for a better part of this last year, and I came into an example of a type that breaks the interface, and I honestly have no idea why. (Also doesn't work with original type)

Counter example of the interface not correctly idenying foo.test

@nroboto do you know what's going on here? Am i missing something?

Collapse
 
nroboto profile image
Mike Nightingale

The issue is that in the Subpath type we exclude all of the properties of an array, which includes the length property, this results in foo.length being excluded even though in this case length is a property on an object. One way to fix this is to change the exclusion to only apply if the type is an array:

type ExcludeArrayKeys<T> = Exclude<keyof T, T extends any[] | readonly any[] ? keyof any[] : never>

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

Here's the TS playground.

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 β˜ οΈπŸ’€πŸ‘»

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
 
phillyx profile image
Phillyx

Hey Craig, when I use Interface, it is not working on Array.Number

// This is not working
setData(person, 'cars.1.brand', 'BYD-YUAN')
Enter fullscreen mode Exit fullscreen mode

here is the code

Looking forward to Ur help

Collapse
 
phillyx profile image
Phillyx • Edited

Thx to @nroboto . Finally I've solved type-safe array dot notation.

here is the code-intelligence

See here

Collapse
 
phillyx profile image
Phillyx

Update for derivation of array types
type SubArrayPath

= P extends PropertyKey ? ${number} : ${number} | ${number}.${PathTemp<P>}

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
 
sirseanofloxley profile image
Sean Allin Newell

Wowza. Gonna have TS written in TS Types soon.

Collapse
 
roszykdamian profile image
Damian Roszyk

Thanks for this great article!

Collapse
 
captainyossarian profile image
yossarian

Awesome article!

with great power comes great responsibility :)))

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