Typescript template literals

December 6th, 2022 - 30 minutes read

TypeScript 4.1 was released 2 years ago. This release contained an exciting and long-awaited feature - template literal types. In our projects, it helped us to improve routing and translations. Not only it reduced bugs count, but it massively improved the Developer Experience.

Problem

It is quite common practice to have some insertable values into strings. I will try to cover the most obvious cases. The first one is router dynamic segments.

1const RouteWithMultipleParams = "/users/:userId/documents/:docId/preview";
2
3navigate("/users/123/documents/456/preview");
4// ✅ Correct
5
6navigate("/users/123/doc/456/preview");
7// 🚨 Invalid - no error - segment mismatch
8
9navigate(buildLink(RouteWithMultipleParams, { userId: 123, documentId: 456 }));
10// 🚨 Invalid - no error - parameter mismatch

Another one is translation interpolation/slots.

1const messages = {
2  msg_title: "Page title",
3  msg_body: "Hello {{name}}",
4};
5
6t("msg_title");
7t("msg_body", { name: "Name" });
8// ✅ Correct
9
10t("msg_footer");
11// 🚨 Invalid - no error - non existing key
12
13t("msg_body");
14// 🚨 Invalid - no error - missing param
15
16t("msg_body", { fullName: "Name" });
17// 🚨 Invalid - no error - invalid param name

As you can notice from provided examples, there are several places where it is easy to make mistakes. Especially when doing large-scale refactorings.

If we would want to just prevent bugs, a viable solution could be tests. But tests will not help with Developer Experience. Only types can do that.

Literal types

For the beginning, a small introduction on how to get TypeScript to infer exact types - main requirement to work with template literals.

When working with string types and using const variables, TypeScript automatically infers the exact type, because it knows that it will never change. If we would be using let instead, it would expand it to string.

1let role = "Admin";
2// string
3
4const role = "Admin";
5// 'Admin'

More interesting cases occur when working with reference-based types like objects and arrays. By default, it automatically widens the type to string[]. So we need to give TypeScript a small hint as const, which makes this value readonly and helps to narrow it down correctly.

1const roles = ["Admin", "User"];
2// string[]
3
4const rolesExact = ["Admin", "User"] as const;
5// readonly ('Admin' | 'User')[]

To get array item type, we can use [number] syntax which extracts values of all array members into a type union.

1type Role = typeof rolesExact[number];
2// 'Admin' | 'User';

Template literal types

Template literals are strings inside backtick ``. We can create literal types by inserting other types into slots. This way TypeScript creates a union of all possible value pairs.

1type Letter = "A" | "B" | "C";
2type Digit = 1 | 2 | 3;
3
4type Values = `${Letter}-${Digit}`;
5// 'A-1' | 'A-2' | 'A-3' |
6// 'B-1' | 'B-2' | 'B-3' |
7// 'C-1' | 'C-2' | 'C-3'

Conditional types

Before going to type extraction, I want to explain a topic that is necessary for next section. TypeScript on type level does not have "if" as JavaScript has, but it has a similar concept called Conditional types. It checks if the provided value satisfies the expected condition.

1type Result1 = "Value" extends string ? "String" : "Not string";
2// String
3
4type Result2 = 100 extends string ? "String" : "Not string";
5// Not string

Type extraction

Creating new literal types is fun, but to me, the most important feature is extracting values from existing literals.

TypeScript has a keyword infer which allows to extract part of literal. But to use infer, it has to be inside conditional type. So if you provided value satisfies expected format, it collects subpart values into an union.

1type Values = "A-1" | "A-2" | "A-3";
2
3type Digits = Values extends `${string}-${infer Digit}` ? Digit : never;
4// '1' | '2' | '3'

But infer is not limited to single value. We can infer multiple values.

1type Value = "Red/Green";
2
3type Color = Value extends `${infer FirstPart}/${infer Remaining}`
4  ? FirstPart | Remaining
5  : never;
6// 'Red' | 'Green'

We can even infer three or any other number of values.

1type Value = "Red/Green/Blue";
2
3type Color = Value extends `${infer First}/${infer Second}/${infer Third}`
4  ? First | Second | Third
5  : never;
6// 'Red' | 'Green' | 'Blue'

But this only fits that specific case. If we want to infer a variable number of segments, we need to have a better solution. Sadly, TypeScript does not have "loop" syntax on types. But there is another way how to achieve this - using recursion.

So instead of adding more and more parameters to infer, we nest the same condition. After this change, you can see that it is almost identical to the outer one.

1type Value = "Red/Green/Blue";
2
3type Color = Value extends `${infer FirstPart}/${infer Remaining}`
4  ?
5      | FirstPart
6      | (Remaining extends `${infer SecondPart}/${infer ThirdPart}`
7          ? SecondPart | ThirdPart
8          : never)
9  : never;
10// 'Red' | 'Green' | 'Blue'

To implement recursion, we need a way to pass parameters into types. For that, I will be using another TypeScript construct called Generic types. So let's create Split<Value> generic type which does the same string splitting action.

1type Split<Value> = Value extends `${infer FirstPart}/${infer Remaining}`
2  ?
3      | FirstPart
4      | (Remaining extends `${infer SecondPart}/${infer ThirdPart}`
5          ? SecondPart | ThirdPart
6          : never)
7  : never;
8
9type Color = Split<"Red/Green/Blue">;
10// 'Red' | 'Green' | 'Blue'

Now, all that is left to do is just replace the nested condition with the Split generic type.

Another small detail is that I have also changed "else" case from never to Value to infer the last value correctly.

1type Split<Value> = Value extends `${infer FirstPart}/${infer Remaining}`
2  ? FirstPart | Split<Remaining>
3  : Value;
4
5type Color = Split<"Red/Green/Blue">;
6// 'Red' | 'Green' | 'Blue'

After all these changes, this part of code works with a variable count of inferable segments. As we got familiar with core theoretical parts, let's dive deep into how to apply this to actual problems.

Routes solution

Before starting extraction, it is important to identify start & end separators. With routes, we know that dynamic segments are prefixed with : symbol. For end separator, we can use /. Also with routes, static segments are not important so we can skip those.

Pro tip: To skip part of string that usually proposed solution is to use something like ${infer _Prefix}. But it is not an optimal solution, because TypeScript has to spend resources trying to infer value that we won't be using anyway. So instead, I would recommend using ${string}, which matches any string (even an empty one).

With route segments there is one additional problem - the dynamic segment may be the last one. In that case, we do not have an end separator. To overcome this, nested "infer" is needed. If we fail to match start and end separators, try to match one more time, but just with start separator. If it succeeds, no need to continue recursion because there cannot be more values.

1type Params<Route> = Route extends `${string}:${infer Param}/${infer Rest}`
2  ? Param | Params<Rest>
3  : Route extends `${string}:${infer Param}`
4  ? Param
5  : never;

Params extraction is the first part. Now to utilize it, let's create a buildLink function (or you could have your own navigate wrapper).

1const buildLink = <Route extends string>(
2  template: Route,
3  ...[params]: Params<Route> extends never
4    ? []
5    : [Record<Params<Route>, string | number>]
6) => {
7  if (!params) {
8    return template;
9  }
10
11  return Object.keys(params).reduce<string>((acc, key) => {
12    return acc.replace(`:${key}`, String(params[key as keyof typeof params]));
13  }, template);
14};

In this implementation we have one quite difficult requirement - params should be required, if a route has dynamic segments, and not possible when it does not have any. TypeScript has only 2 ways (to my knowledge) how to achieve this - function overload or rest arguments as named tuple. It is quite a broad topic itself, so I will cover it in another blog post in the future.

Finally, let's see the results. Correct usage remains the same as before, but what's important, is that all invalid cases now throw TypeScript errors.

1const Route = "/users";
2const RouteWithMultipleParams = "/users/:userId/documents/:docId/preview";
3
4navigate(buildLink(RouteWithMultipleParams, { userId: 123, docId: 456 }));
5navigate(buildLink(Route));
6// ✅ Correct
7
8navigate(buildLink(RouteWithMultipleParams, { userId: 123, documentId: 456 }));
9// 🚨 TS2345: Argument of type '{ userId: number; documentId: number; }'
10//    is not assignable to parameter of type 'Record<"userId" | "docId",
11//    string | number>'.
12
13navigate(buildLink(RouteWithMultipleParams));
14// 🚨 TS2554: Expected 2 arguments, but got 1.
15
16navigate(buildLink(Route, { userId: 123, documentId: 456 }));
17// 🚨 TS2554: Expected 1 arguments, but got 2.

Translations solution

Usually, translations are stored in JSON file. It is possible to import this file into TypeScript, but sadly, it infers all values as a plain string.

To infer values correctly, translations have to be moved into .ts file as a simple object. But we also need to add as const assertion so that values would not be widened to string.

1const EnTranslations = {
2  msg_title: "Page title",
3  msg_body: "Hello {{name}}",
4} as const;

Translation type extraction is quite simple because it has explicit start {{ and end }} separators.

1type Params<Message> =
2  Message extends `${string}{{${infer Param}}}${infer Rest}`
3    ? Param | Params<Rest>
4    : never;

Similarly like with Routes, we need to create t (or translate) function which does params interpolation.

If you are using i18n-next or any similar library, you can leave translate functionality unchanged, and just typecast function declaration.

1export type Translations = typeof EnTranslations;
2
3type MessageKey = keyof Translations;
4
5type Translate = <Key extends MessageKey>(
6  key: Key,
7  ...[params]: Key extends MessageKey
8    ? Params<Translations[Key]> extends never
9      ? []
10      : [Record<Params<Translations[Key]>, string | number>]
11    : never
12) => string;
13
14const t: Translate = (key, ...[params]) => {
15  const message = EnTranslations[key];
16
17  if (!params) {
18    return message;
19  }
20
21  return Object.keys(params).reduce((acc, key) => {
22    return acc.replace(
23      `{{${key}}}`,
24      String(params[key as keyof typeof params])
25    );
26  }, message);
27};

Like with routes, we want to have variable arguments count. So here as well I have used tuple rest spread params.

But with translations, we have an additional interesting case. If we try to pass non-existent key 'msg_footer', TypeScript reports an error - function expected 2 arguments instead of 1. But the issue is that such key does not exist. As soon as we add second argument, we see a correct error. So it seems, that if TypeScript gets 2 errors on the same line, one error takes priority over another. To improve this, we need to a add second check Key extends MessageKey. If it does extend, then evaluate it to never. This eliminates arguments count error and TypeScript displays correct error.

With typesafe t function, we see similar results as with routes - TypeScript errors where either key or params mismatches in any way.

1t("msg_title");
2t("msg_body", { name: "Name" });
3// ✅ Correct
4
5t("msg_footer");
6// 🚨 TS2345: Argument of type '"msg_footer"' is not assignable
7// to parameter of type '"msg_title" | "msg_body"'.
8
9t("msg_body");
10// 🚨 TS2554: Expected 2 arguments, but got 1.
11
12t("msg_body", { fullName: "Name" });
13// 🚨 TS2345: Argument of type '{ fullName: string; }' is not assignable
14// to parameter of type 'Record<"name", string | number>'.

Another cool thing is that it also provides great IDE autocomplete functionality.

Summary

In my blog post, I just shared 2 possible use cases, but you can check the cool examples other developers have built. Here are some of them:

1const innerValue = get(user, "project.name");
2// 🚨 current: any;
3// ✅ with infer: string;
4
5const element = querySelector("div.button > a");
6// 🚨 current: Element;
7// ✅ with infer: HTMLAnchorElement | null;
8
9const results = query("SELECT name, surname FROM USERS");
10// 🚨 current: any;
11// ✅ with infer: { name: unknown, surname: unknown }

Template literals are a powerful tool. At first glance, they seem very complex, but after getting familiar, that feeling disappears. I hope that my post will inspire you to improve your codebase in a similar way.