Enforcing Localization through Types

Posted:

When building web applications, enforcing that strings be localized to the user’s preferred language can sometimes be achieved via lint rules. But what if we could enforce proper localization using types in Typescript?

Defining a Localized Type

Typescript doesn’t natively provide an Opaque type that we can use to define a string that has already been localized. If the data looks like a string, Typescript will consider it a string. We can however use utility types that simulate opaque types, like the Opaque definition in type-fest:

import { Opaque } from 'type-fest';

type LocalizedString = Opaque<'LocalizedString', string>;

Playground example

Here is a short example where we create a localized string and try to use a raw string and our localized string in a function:

import { Opaque } from 'type-fest';

type LocalizedString = Opaque<'LocalizedString', string>;

function createLocalizedString(s: string): LocalizedString {
    return s as LocalizedString;
}

function example(s: LocalizedString) {
    return s;
}

console.log(example('test')); // Will throw a type error
console.log(example(createLocalizedString('test'))); // Works correctly

Playground example

Now we have a type that we can use in our function and components to denote that we expect an already localized string to be used.

Enforcing in Components

The simplest way we can enforce that strings have already been localized is by using the type in our component’s props interface:


interface ButtonProps {
  label: LocalizedString;
}

function Button(props: ButtonProps) {
  return <button>{props.label}</button>;
}

Now if we try to use that component without a localized string, we get an error:

import React from 'react';
import { Opaque } from 'type-fest';

type LocalizedString = Opaque<'LocalizedString', string>;

function createLocalizedString(s: string): LocalizedString {
    return s as LocalizedString;
}

interface ButtonProps {
  label: LocalizedString;
}

function Button(props: ButtonProps) {
  return <button>{props.label}</button>;
}

function Example() {
    return (
        <>
            <Button label="Test" />
            <Button label={createLocalizedString("Test")} />
        </>
    );
}

Playground example

Localizing Strings

So far, we’ve been using a utility createLocalizedString to create and use the LocalizedString type. This utility is only really practical in unit tests. For real applications, we’ll want to use a translation function from react-i18next or next-i18next to do the heavy lifting. Then we just wrap the translation functions that are provided in order to use our type:

import React, { useCallback } from 'react';
import { Opaque } from 'type-fest';
import { useTranslation } from 'react-i18next';

type LocalizedString = Opaque<'LocalizedString', string>;

function createLocalizedString(s: string): LocalizedString {
    return s as LocalizedString;
}

interface ButtonProps {
  label: LocalizedString;
}

function Button(props: ButtonProps) {
  return <button>{props.label}</button>;
}

function useLocalizedTranslation() {
    const { t : originalTranslate } = useTranslation();

    const t = useCallback((key: string, defaultString: string) => {
        return originalTranslate(key, defaultString) as LocalizedString;
    }, [originalTranslate]);

    return { t };
}

function Example() {
    const { t } = useLocalizedTranslation();
    return (
        <>
            <Button label={t("test", "Test")} />
        </>
    );
}

Playground example

Here we use useTranslation from react-i18next and wrap the t translation function that is returned to override the type it gives us.

Now when engineers go to use the Button component, they will know they need a LocalizedString, and the most straightforward way to get it will be to use the i18next utilities that we also provide.