Implementing the translate function with TypeScript

Main blog image

TypeScript 4.1 has brought a nice feature called Template literal types. Many authors have written about it, so I won't stop here and explain how it works. Instead, I would like to show you an example of how we can use it. Let's not waste our time!

Every i18n module has a translate function (also can be named as short t). Assume that we have a function with such a signature:

declare function translate(path: string, variables?: Record<string, string>): string;

As the first parameter, we can pass a dot delimited keys of a translation object. It can be a JSON object or JavaScript object, whatever. The main thing is that TypeScript can't check path correctness and possible variable existence in the translated sentence with such signature. Besides, you must be careful with what you are writing. Thanks to the new feature we can fix it.

At first, we need to have a type that describes our translation object. It is up to you because no one knows how big and diversified it can be. Suppose we have it already. And our translation function should know it. Otherwise, TypeScript can't make any prediction on unknown object shapes.

declare function translate<T>(
  path: string,
  variables?: Record<string, string>,
): string;

But it isn't helpful yet. Let's move on. Our goal is to let TypeScript check if keys of the path parameter exist in the translation object and infer the following keys based on typed information.

We can accomplish it by creating all possible ways to structure a path to all values in the object. It may be more clear if I show it with an example.

interface T {
  a: {
    b: string;
    c: string;
  };
  d: string;
}

We have an interface T. For an object with such shape, we can build three possible path parameters:

  1. a.b
  2. a.c
  3. d

And when you write a, TypeScript will add a hint that the following path part can be either .b or .c. Do you get the idea?

But how we can get all possible key combinations? We can recursively iterate over an object type and, for all top keys, get an array of all keys of inner objects that we can reach. It looks like the following:

type PathKeys<T> = T extends string
  ? []
  : {
      [K in keyof T]: [K, ...PathKeys<T[K]>];
    }[keyof T];

Stop a little bit and try to understand the type. It is a little bit tricky but works as expected. Can you see why?

Ok, we have tuples of all possible key combinations. But it isn't what we want. So we should join them to have a union type of all combinations. For that, we should learn a new type that will concatenate the values of each tuple. Let's call it Join 😎

type Join<T extends string[], Delimiter extends string> = T extends []
  ? never
  : T extends [infer F]
  ? F
  : T extends [infer F, ...infer Other]
  ? F extends string
    ? `${F}${Delimiter}${Join<Extract<Other, string[]>, Delimiter>}`
    : never
  : string;

It is a bit trickier than the previous type, and we finally start using the new TypeScript feature. It accepts an array of strings (keys) and delimiter value that is the dot in our case. Then recursively iterates over the array(tuple). Unfortunately, TypeScript can't understand that the Other type is another tuple with all values except the F and infers it as unknown[], but we can fix it by providing a hack with an Extract type that narrow Other to a tuple of strings.

And that's it! Now we should make path parameter as union type of all keys combinations.

interface T {}

declare function translate<P extends Join<PathKeys<T>, '.'>>(
  paths: P,
  variables?: Record<string, string>,
): string;

Yay!

But one thing left. Our text can have variables, and we can provide values for them. Are we able to let TypeScript check for variable presence in translation sentences and check for name correctness? Yes, we are.

To do that, we should analyze a string and check whether it has a particular template for a variable. Suppose it is {variableName}. As the previous types, the new type will also be recursive.

type SearchForVariable<A extends string> =
  A extends `${infer A}{${infer B}}${infer C}`
    ? SearchForVariable<A> | B | SearchForVariable<C>
    : never;

We split a string into three parts: before a template, template itself and after the template. Returned union type will contain all variable names that exist in the string value or nothing.

But how we actually can get the correct value for checking? For that, we should match the initial translation object and path to the string value. Do you know what it means? Yes, a new type! Let's dig into it.

type Variables<
  T extends string | object,
  Path extends string,
  Delimiter extends string,
> = Path extends `${infer A}${Delimiter}${infer O}`
  ? A extends keyof T
    ? Variables<Extract<T[A], string | object>, O, Delimiter>
    : never
  : Path extends `${infer A}`
  ? A extends keyof T
    ? SearchForVariable<Extract<T[A], string>>
    : never
  : never;

It is the trickiest type of this tutorial. At first, we should get rid of the delimiter. Then every part of the inferred values is recursively checked. If at least one part contains a variable template, then it is returned. If there are two variables or more, then the union type of their names is the result. So, the final type of translate function looks like this:

interface T {}

declare function translate<P extends Join<PathKeys<T>, '.'>>(
  paths: P,
  variables?: Record<Variables<T, P, '.'>, string>,
): string;

We have built a complete translate function that TypeScript can check for correctness. You must have a concrete translation type object with values as literal string types for it to work. Otherwise, TypeScript won't make any checks 🤷‍♂️.

As a bonus, you can allow spaces between {} and variable names. But in that case, you should trim extra whitespaces from the name. A new type called Trim can help you with that.

type Trim<A extends string> = A extends ` ${infer B}`
  ? Trim<B>
  : A extends `${infer C} `
  ? Trim<C>
  : A;

Also, you should correct the SearchForVariable type to perform the trim operation over the variable's name.

type SearchForVariable<A extends string> =
  A extends `${infer A}{${infer B}}${infer C}`
    ? SearchForVariable<A> | Trim<B> | SearchForVariable<C>
    : never;

A small update. And now we are finally done. The final implementation you can see here.

Thank you for reading. TypeScript is a great language and gets more power with each release. I hope you had fun and learned something new. May the wisdom be with you.