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 likeconst hello = "Hello World";
, orconst 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:
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:
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 π
Top comments (14)
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.
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
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
I'm not sure if this is easier to understand than the original though.
Here's the result.
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?
The issue is that in the
Subpath
type we exclude all of the properties of an array, which includes thelength
property, this results infoo.length
being excluded even though in this caselength
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:Here's the TS playground.
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:
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 π
Hey Craig, when I use
Interface
, it is not working onArray.Number
here is the code
Looking forward to Ur help
Thx to @nroboto . Finally I've solved type-safe array dot notation.
here is the code-intelligence
See here
Update for derivation of array types
type SubArrayPath
= P extends PropertyKey ?
${number}
:${number}
|${number}.${PathTemp<P>}
One question about
PathValue
. With your implementation above, we getnever
withget(user, 'projects.0.name')
.But it will get the correct type with one small change:
See here.
Wowza. Gonna have TS written in TS Types soon.
Thanks for this great article!
Awesome article!
with great power comes great responsibility :)))
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