Type-safe translations in TypeScript

Brieuc KaisinBrieuc Kaisin
11 min read

As a developer, you may have encountered the need to internationalise your application. For that, you may either have used a library or written your own solution. In this article, we will explore how to create a type-safe translation system in TypeScript.

The problem

Imagine your application needs to support multiple languages:

enum Language {
  EN = "en",
  FR = "fr",
}

For every string in your application, you need to provide translations for each language:

type Translations = Record<Language, string>;

However, translations are not static strings. They can contain placeholders that need to be replaced:

const translations: Translations = {
  [Language.EN]: "Hello, {{name}}!",
  [Language.FR]: "Bonjour, {{name}}!",
};

Then, you need a function to replace the placeholders:

function replacePlaceholders(translation: string, placeholders: Record<string, string>): string {
  return Object.entries(placeholders).reduce((acc, [key, value]) => {
    return acc.replace(`{{${key}}}`, value);
  }, translation);
}

Finally, you can use the translation system in your application:

function translate(translations: Translations, language: Language, placeholders?: Record<string, string>): string {
  return placeholders ? replacePlaceholders(translations[language], placeholders) : translations[language];
}

This approach has a major drawback: it is not type-safe regarding placeholders. Consider this:

const translation = translate(translations, Language.EN, { namee: "John" });

The code compiles without errors, but at runtime, the placeholder {{name}} remains unreplaced. A simple typo becomes a bug.

The solution: type-safe translations

To make the translation system type-safe, we can use TypeScript's literal, mapped and conditional types. First, we define a type for checking if a string contains specific placeholders:

type StringHasValidPlaceholders<
  S extends string,
  P extends readonly string[],
> = P extends readonly [infer PHead, ...infer PTail]
  ? PHead extends string
    ? S extends `${infer SHead}{{${PHead}}}${infer STail}`
      ? PTail extends readonly string[]
        ? StringHasValidPlaceholders<`${SHead}${STail}`, PTail>
        : false
      : false
    : false
  : S extends `${string}`
    ? true
    : false;

This type checks if a string S contains all the placeholders P, in any order. Note that we don't check if the placeholders are duplicated (see the type DuplicatedPlaceholders in the bonus section to see the idea of how to do it).

For example:

type Test1 = StringHasValidPlaceholders<"Hello, {{name}}!", ["name"]>; // true
type Test2 = StringHasValidPlaceholders<"Hello, {{name}}!", ["namee"]>; // false
type Test3 = StringHasValidPlaceholders<"Hello, {{name}}!", ["name", "age"]>; // false

Next, we can validate translations using the following types:

type OnlyTrues<T extends Record<string, boolean>> = T[keyof T] extends true
  ? true
  : false;

type ValidTranslations<
  T extends Translations,
  P extends readonly string[],
> = OnlyTrues<{
  [L in Language]: StringHasValidPlaceholders<T[L], P>;
}>;

/* Example */
const translations = {
  [Language.EN]: "Hello, {{name}}, you are {{age}} years old!",
  [Language.FR]: "Bonjour, {{name}}, vous avez {{age}} ans!",
} as const satisfies Translations;

type Test4 = ValidTranslations<typeof translations, ["name", "age"]>; // true
type Test5 = ValidTranslations<typeof translations, ["namee"]>; // false

Finally, to check if the provided placeholders match the translation at compile time with the following types:

const expectType: <T>(_: T) => void = <T>(_: T): void => void 0;
const check: <_ extends true>() => void = () => void 0;

Type expectType is used to check the type of a value, while type check is used to check if a type is true. With our example, we can now write the following code:

expectType<ValidTranslations<typeof translations, ["name", "age"]>>(true);
expectType<ValidTranslations<typeof translations, ["namee"]>>(true); // compile error

/* More concise version */
check<ValidTranslations<typeof translations, ["name", "age"]>>();
check<ValidTranslations<typeof translations, ["namee"]>>(); // compile error

Time to translate !

Now we can check if the translations are valid at compile time, it's time to implement the translate function: For that, we will use the following types:

type ValidateStringPlaceholders<
  S extends string,
  P extends readonly string[],
> = StringHasValidPlaceholders<S, P> extends true ? S : never;

type TypeSafeTranslations<
  P extends readonly string[],
  T extends Translations,
> = {
  [L in Language]: ValidateStringPlaceholders<T[L], P>;
};

type PlaceholdersReplacement<P extends readonly string[]> = Record<
  P[number],
  string | number
>;

type ReplacePlaceholders<
  S extends string,
  P extends readonly string[],
  R extends PlaceholdersReplacement<P>,
> = P extends readonly [infer PHead, ...infer PTail]
  ? PHead extends string
    ? S extends `${infer SHead}{{${PHead}}}${infer STail}`
      ? PTail extends readonly string[]
        ? ReplacePlaceholders<`${SHead}${R[PHead]}${STail}`, PTail, R>
        : never
      : never
    : never
  : S;

Let's explain the types:

  • ValidateStringPlaceholders checks if a string S contains all the placeholders P. If it does, it returns the string S, otherwise it returns never.

  • TypeSafeTranslations applies ValidateStringPlaceholders to all translations in T.

  • PlaceholdersReplacement is a record of placeholders and their replacements.

  • ReplacePlaceholders replaces placeholders in a string S with the values from R, which are replacements for placeholders P.

For example:

type Test6 = ReplacePlaceholders<
  "Hello {{name}}, you are {{age}} years old",
  ["name", "age"],
  { name: "John"; age: 42 }
>;

This type is the literal string "Hello John, you are 42 years old".

Dummy implementation

Now we can implement the translate function:

function translateDummy<
  P extends readonly string[],
  T extends Translations,
  L extends Language,
  R extends PlaceholdersReplacement<P>,
>(
  translations: TypeSafeTranslations<P, T>,
  lang: L,
  replacements: R,
): ReplacePlaceholders<TypeSafeTranslations<P, T>[L], P, R> {
  return Object.keys(replacements).reduce(
    (acc: string, key: keyof R) =>
      acc.replace(`{{${key.toString()}}}`, replacements[key].toString()),
    translations[lang],
  ) as ReplacePlaceholders<TypeSafeTranslations<P, T>[L], P, R>;
}

Let's test it:

const translations = {
  [Language.EN]: "Hello {{name}}, you are {{age}} years old",
  [Language.FR]: "Bonjour {{name}}, vous avez {{age}} ans",
} as const satisfies Translations;

const translation = translateDummy(translations, Language.EN, { name: "John", age: 42 } as const);

We have issues with this implementation:

  • The translateDummy function is not type-safe, because the placeholders type-parameters are not inferred.

  • For this reason, the type of translation is string instead of the expected "Hello John, you are 42 years old".

To avoid this, we can specify the type-parameters explicitly:

const translation: "Hello, John, you are 42 years old!" = translateDummy<
  ["name", "age"],
  typeof translations,
  Language.EN,
  { name: "John"; age: 42 }
>(translations, Language.EN, {
  name: "John",
  age: 42,
} as const);

Alternatively, we can give more information to the TypeScript compiler by "tagging" the translations:

const taggedTranslations: TypeSafeTranslations<
  ["name", "age"],
  typeof translations
> = translations;

const translated: "Hello, John, you are 42 years old!" = translateDummy(
  taggedTranslations,
  Language.EN,
  {
    name: "John",
    age: 42,
  } as const,
);

This is very cumbersome, so we need a better solution.

Final implementation

To fix the problems of the dummy implementation, we can leverage the TypeScript compiler to do the type inference for us. Let's start by defining a type that extracts the placeholders from a translation:

type ExtractPlaceholders<S extends string> =
  S extends `${string}{{${infer P}}}${infer T}`
    ? readonly [P, ...ExtractPlaceholders<T>]
    : readonly [];

Then, we can define a type that extracts the placeholders from all translations:

type TranslationPlaceholders<T extends Translations> = {
  [L in Language]: ExtractPlaceholders<T[L]>;
}[Language];

Let's see this type in action:

expectType<TranslationPlaceholders<typeof translations>>(["name", "age"]); // it compiles

Let's now put everything together to build the Translate type:

type Translate<
  T extends Translations,
  R extends PlaceholdersReplacement<TranslationPlaceholders<T>>,
  L extends Language,
> = ReplacePlaceholders<T[L], TranslationPlaceholders<T>, R>;

This type takes a translation T, a placeholder replacement R, and a language L, and returns the translated string type in the specified language, with the placeholders replaced.

Let's check if it works:

expectType<
  Translate<typeof translations, { name: "John"; age: 42 }, Language.FR>
>("Bonjour, John, vous avez 42 ans!"); // it compiles, great!

Now we can implement the translate function:

function translate<
  T extends Translations,
  L extends Language,
  R extends PlaceholdersReplacement<TranslationPlaceholders<T>>,
>(translations: T, lang: L, replacements: R): Translate<T, R, L> {
  return Object.keys(replacements).reduce(
    (acc: string, key: keyof R) =>
      acc.replace(`{{${key.toString()}}}`, replacements[key].toString()),
    translations[lang],
  ) as Translate<T, R, L>;
}

Let's test it:

const translated =
  translate(translations, Language.FR, {
    name: "John",
    age: 42,
  } as const);

The type of translated is "Bonjour, John, vous avez 42 ans!". We have a convenient and type-safe translation system! Finally, you can see that the type checks we have done in section 2 with types StringHasValidPlaceholders and ValidTranslations are not necessary anymore, since the Translate type already ensures that the translations are valid at compile time. At the end, the only "complex"types we need are PlaceholdersReplacement, ExtractPlaceholders, TranslationPlaceholders and Translate.

Last but not least, you can observe that the order of the placeholders in the translation does not matter, which is a nice feature of our type-safe translation system (think about languages where the order of words in a sentence is different).

Bonus: type-level error accumulation

Let's now test how the compiler behaves when we provide invalid arguments to the translate function:

  1. Providing an invalid placeholder

     const translations = {
       [Language.EN]: "Hello {{name}}, you are {{age}} years old",
       [Language.FR]: "Bonjour {{name}}, vous avez {{age}} ans",
     } as const satisfies Translations;
    
     const translated = translate(translations, Language.EN, {
       namee: "John", // error is highlighted here
       age: 42,
     } as const);
    

    The compiler will throw an error:

     TS2561: Object literal may only specify known properties, but namee does not exist in type PlaceholdersReplacement<readonly ["name", "age"]>. Did you mean to write name?
    
  2. Forget to provide a placeholder

     const translated = translate(translations, Language.EN, {
       name: "John",
     } as const);
    

    The compiler will throw an error:

     TS2345: Argument of type { readonly name: "John"; } is not assignable to parameter of type PlaceholdersReplacement<readonly ["name", "age"]>.
    
     Property age is missing in type { readonly name: "John"; } but required in type PlaceholdersReplacement<readonly ["name", "age"]>
    
  3. Translations are not valid

     const translations = {
       [Language.EN]: "Hello {{namee}}, you are {{agee}} years old", // 2 errors
       [Language.FR]: "Bonjour {{name}}, vous avez {{age}} ans",
     } as const satisfies Translations;
    
     const translated = translate(translations, Language.EN, {
       name: "John",
       age: 42,
     } as const);
    

    The compiler will throw an error:

     TS2345: Argument of type { readonly name: "John"; readonly age: 42; } is not assignable to parameter of type PlaceholdersReplacement<TranslationPlaceholders<{ readonly en: "Hello {{namee}}, you are {{agee}} years old"; readonly fr: "Bonjour {{name}}, vous avez {{age}} ans"; }>>.
    
     Type { readonly name: "John"; readonly age: 42; } is missing the following properties from type PlaceholdersReplacement<TranslationPlaceholders<{ readonly en: "Hello {{namee}}, you are {{agee}} years old"; readonly fr: "Bonjour {{name}}, vous avez {{age}} ans"; }>>
     : namee, agee
    

Although these error messages are understandable, they are not very user-friendly since they come directly from the TypeScript compiler. To improve the user experience, we can create a custom type to accumulate errors. This type will be a union of all the errors, which can be of three types:

type MissingPlaceholders<
  S extends string,
  P extends readonly string[],
> = Remove<P, ExtractPlaceholders<S>>;

type UnexpectedPlaceholders<
  S extends string,
  P extends readonly string[],
> = Remove<ExtractPlaceholders<S>, P>;

type DuplicatedPlaceholders<
  S extends string,
  P extends readonly string[],
  R extends readonly string[] = [],
> = P extends readonly [infer PHead, ...infer PTail]
  ? PHead extends string
    ? PTail extends readonly string[]
      ? S extends `${infer SHead}{{${PHead}}}${infer SMiddle}{{${PHead}}}${infer STail}`
        ? DuplicatedPlaceholders<
            `${SHead}${SMiddle}${STail}`,
            PTail,
            [...R, PHead]
          >
        : DuplicatedPlaceholders<S, PTail, R>
      : []
    : []
  : R;

Where the type Remove<T, U> removes all the elements of U from T:

type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
    ? true
    : false;

type Remove<
  T extends readonly unknown[],
  U extends readonly unknown[],
> = U extends readonly [infer UHead, ...infer UTail]
  ? Remove<
      T extends readonly [infer Head, ...infer Tail]
        ? Equal<Head, UHead> extends true
          ? Remove<Tail, [UHead]>
          : [Head, ...Remove<Tail, [UHead]>]
        : [],
      UTail
    >
  : T;

Then, we can create a type that accumulates all the errors:


type MissingPlaceholdersErrors<
  S extends string,
  P extends readonly string[],
  R extends string[] = [],
> =
  MissingPlaceholders<S, P> extends [infer MissingHead, ...infer MissingTail]
    ? MissingHead extends string
      ? MissingTail extends readonly string[]
        ? MissingPlaceholdersErrors<
            S,
            MissingTail,
            [...R, `Placeholder '${MissingHead}' is missing`]
          >
        : never
      : never
    : R;

type UnexpectedPlaceholdersErrors<
  S extends string,
  P extends readonly string[],
  R extends string[] = [],
> =
  UnexpectedPlaceholders<S, P> extends [
    infer UnexpectedHead,
    ...infer UnexpectedTail,
  ]
    ? UnexpectedHead extends string
      ? UnexpectedTail extends readonly string[]
        ? UnexpectedPlaceholdersErrors<
            S,
            UnexpectedTail,
            [...R, `Unexpected placeholder '${UnexpectedHead}'`]
          >
        : never
      : never
    : R;

type DuplicatedPlaceholdersErrors<
  S extends string,
  P extends readonly string[],
  R extends string[] = [],
> =
  DuplicatedPlaceholders<S, P> extends [
    infer DuplicatedHead,
    ...infer DuplicatedTail,
  ]
    ? DuplicatedHead extends string
      ? DuplicatedTail extends readonly string[]
        ? DuplicatedPlaceholdersErrors<
            S,
            DuplicatedTail,
            [...R, `Duplicated placeholder '${DuplicatedHead}'`]
          >
        : never
      : never
    : R;

type AccumulateErrors<S extends string, P extends readonly string[]> = Readonly<
  [
    ...MissingPlaceholdersErrors<S, P>,
    ...UnexpectedPlaceholdersErrors<S, P>,
    ...DuplicatedPlaceholdersErrors<S, P>,
  ]
>;

// FINALLY
type TranslationsErrors<T extends Translations, P extends readonly string[]> = {
  [L in Language]: AccumulateErrors<T[L], P>;
};

Let's now play with our new magic type. First, if there is no error:

const translations = {
  [Language.EN]: "Hello, {{name}}, you are {{age}} years old!",
  [Language.FR]: "Bonjour, {{name}}, vous avez {{age}} ans!",
} as const satisfies Translations;

type NoErrors = TranslationsErrors<typeof translations, ["name", "age"]>;

The type NoErrors is { en: readonly []; fr: readonly [] }, which means that there are no errors in the translations. To ensure that, we can use the following type:

const noTranslationErrors = {
  [Language.EN]: [],
  [Language.FR]: [],
} as const satisfies Record<Language, readonly []>;

expectType<NoErrors>(noTranslationErrors); // it compiles

Now, let's introduce errors:

const translations = {
  [Language.EN]: "Hello, {{namee}}, you are {{age}} and {{age}} years old!",
  [Language.FR]: "Bonjour, {{name}} vous avez 42 ans!",
} as const satisfies Translations;

type Errors = TranslationsErrors<typeof translations, ["name", "age"]>;

In this example, the type Errors is:

{
  en: readonly ["Placeholder 'name' is missing", "Unexpected placeholder 'namee'", "Duplicated placeholder 'age'"];
  fr: readonly ["Placeholder 'age' is missing"];
}

Eurêka! We made the TypeScript compiler accumulate user-friendly errors at the type level !

Conclusion

In this article, we have laid the first stones of a type-safe translation system in TypeScript. We have seen how to check if translations are valid at compile time, how to replace placeholders in translations, and how to accumulate errors at the type level.

This system is not perfect, and there are many improvements that can be made. We should also consider the performance impact of using complex type inference in a large codebase.

For a real-world application, you can have a look at the wonderful production-ready typesafe-i18n library, which relies on similar concepts than the ones presented in this article.

10
Subscribe to my newsletter

Read articles from Brieuc Kaisin directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Brieuc Kaisin
Brieuc Kaisin