Literal types in TypeScript allow for narrowing down the type to the exact value. You can communicate in the type system that the value has to be exactly this string not a string or one of those particular numbers and not just any number. Until TypeScript 4.1, we had three literal types: strings, numbers, and booleans. TypeScript 4.1 introduced the fourth literal type: template literal.
The goal of the article is to play with literal types to get to know them better.
Basic Template Literal
Template literal types expand on what's already possible with string literals. Let's think of a function that can add a CSS class from the Animate.css library.
type FadeInClassNames =
"fadeIn"
| "fadeInDown"
| "fadeInDownBig"
| "fadeInLeft"
| "fadeInLeftBig"
| "fadeInRight"
| "fadeInRightBig"
| "fadeInUp"
| "fadeInUpBig"
| "fadeInTopLeft"
| "fadeInTopRight"
| "fadeInBottomLeft"
| "fadeInBottomRight";
type FadeOutClassNames =
"fadeIn"
| "fadeOutDown"
| "fadeOutDownBig"
// ...;
type ClassNames = FadeInClassNames | FadeOutClassNames /* | BounceIn... */;
declare function addAnimateClass(element: Element, className: ClassNames);
What initially seems to be a walk in the park, can quickly get annoying. That's is how we can get rid of repetition using template literals.
type Direction = `${"Down" | "Left" | "Right" | "Up"}${"" | "Big"}`;
type Position = "Top" | "Bottom";
type Entry = "In" | "Out";
type FadeClassNames = `fade${Entry}${"" | Direction | Position}`;
// "fadeIn" | "fadeInDown" | "fadeInDownBig" ...
type ClassNames = FadeClassNames /* | Bounce... */;
declare function addAnimateClass(element: Element, className: ClassNames);
Union types of string literals can be combined to create much bigger union types that would be difficult to maintain otherwise. I say this is a typical use case without really taking it to the limit.
Template Literals Inference
We can also infer portions of the template literal type. You might not be familiar with type inference or conditional types, so let's start with a quick example of the type that can "unpack" a type resolved by a Promise.
type UnpackPromise<T extends Promise<any>> = T extends Promise<infer R>
? R
: never;
type N = UnpackPromise<Promise<number>>; // number
Inferred type R
acts as a variable for a type. When the type T
is a subtype of type Promise
, then we can store a type resolved by a promise in R
. In this example, type T
always has to be a Promise, otherwise, we return never
to indicate that it will never happen.
type Entry = "In" | "Out";
type InOrOut<T> = T extends `fade${infer R}` ? R : never;
type I = InOrOut<"fadeIn">; // "In"
type O = InOrOut<"fadeOut">; // "Out"
This rather boring example shows the basics of template literal inference. By substitution, we're able to infer the type of R
.
A new requirement for our addAnimateClass
is that it should return true or false depending on whether the element is visible (true) or hidden (false) after the animation. The Entry
fragment of the class name indicates that.
declare function addAnimateClass<C extends ClassNames, E extends Entry>(
element: Element,
className: C
): C extends `fade${E}${infer P}`
? C extends `fade${infer R}${P}`
? R extends "In"
? true
: false
: never
: never;
declare const elem: Element;
addAnimateClass(elem, "fadeInUp"); // true
addAnimateClass(elem, "fadeOut"); // false
Let's say that the readability of this solution is suboptimal. Although not a valid TypeScript code, rewriting nested ternary operators as if statements helps a little.
if (C extends `fade${Entry}${infer P}`) {
if (C extends `fade${infer R}${P}`) {
return R extends "In" ? true : false;
}
}
The first step is to use the Entry
type to infer the rest of the class name that we need for further substitution and store it as type P
. Next, we infer R
that can be either "In"
or "Out"
. When R
is a subtype of "In"
(so it's just "In"
), the function returns true.
Template Literals Mapping
In the next, advanced example, we have to understand how to work with recursive types. This is how you can use recursion to trim empty characters in the template literal.
type TrimRight<T extends string> = T extends `${infer R} ` ? TrimRight<R> : T;
TrimRight<"lol "> // "lol"
As long as the string of type T
has a space on the right side, we infer the rest of the string and call TrimRight
on the inferred portion. When there are no spaces left, we return T
.
Recursive calls are limited. I hit the error: Type instantiation is excessively deep and possibly infinite after adding 48 spaces. This isn't an issue for us, but it's good to know that sometimes we can mitigate that by allowing your types to resolve quicker. To fix it, let's trim more than once space at a time.
type TrimRight<T extends string> = T extends `${infer R} `
? TrimRight<R>
: T extends `${infer R} `
? TrimRight<R>
: T;
TrimRight<"lol "> // "lol"
Advanced Template Literal
The advanced example is going to be a type of a fixture
function that accepts a short description of fake data that we need in our tests. In case you're not familiar with this idea, you might be interested in my other article: Fixtures, the way to manage sample and test data
.
This is an interface of entities that we're going to for testing.
interface Entities {
user: User;
order: Order;
}
That's how we want to use our expressive fixture
function. For now, it's simple, but don't worry, we'll complicate our task in a moment.
fixture("user list") // User[]
fixture("single order") // Order
With the limited scope of possible inputs, we could get away with overloading our function. This way we can hardcode each description with a concrete type.
declare function fixture(what: "single user"): User;
declare function fixture(what: "user list"): User[];
declare function fixture(what: "single order"): Order;
declare function fixture(what: "order list"): Order[];
fixture("single user") // User
fixture("order list") // Order[]
We can achieve the same with template literals and referencing Entities
by key.
declare function fixture<
K extends keyof Entities,
T extends `${K} list` | `single ${K}`
>(what: T): T extends `single ${infer R}`
? R extends K
? Entities[R]
: never
: T extends `${infer R} list`
? R extends K
? Entities[R][]
: never
: never;
fixture("single user") // User
fixture("order list") // Order[]
The major difference from the previous example is that now the inferred variable is used as a key. Although for us it's clear it can be either "user" or "order", this requires an additional condition to make sure R
is a subtype of keyof Entities
.
The new requirement is to support fixtures that accept modifiers. That means that we can use the fixture
function like this:
fixture("random order list"); // Order[]
fixture("invalid single user"); // User
Fun stuff, right? The Entities
will now consist of factory functions for each type. The fixture
function is going to use a modifier for type substitution.
type MatchingModifier = "" | "random " | "invalid "
type Modifier = TrimRight<MatchModifier>;
interface Entities {
user: (modifer: Modifier) => User;
order: (modifer: Modifier) => Order;
}
declare function fixture<
K extends keyof Entities,
T extends `${MatchModifier}${K} list` | `${MatchModifier}single ${K}`
>(what: T): T extends `${MatchModifier}single ${infer R}`
? R extends K
? ReturnType<Entities[R]>
: never
: T extends `${MatchModifier}${infer R} list`
? R extends K
? ReturnType<Entities[R]>[]
: never
: never;
const orders = fixture("random order list"); // Order[]
const user = fixture("invalid single user"); // User
Conclusion
Template literals are a powerful feature, often easier to use than it looks at the first sight. Most of the time, it's splitting and concatenating strings based on simple conditions. When overused, template literals, can give your IDE a hard time as they can get quite complex and CPU consuming, especially when combined with recursion. Complex types are also not going to be a lot of fun to maintain unless you're really interested in TypeScript's type system.
For more examples, you can check out my implementation of the set
function type you might know from lodash. There's also ghoullier/awesome-template-literal-types repository that has more interesting examples.
Photo by Bartosz Kwitkowski on Unsplash.