Why I wrote "hydrate‑text" library
Internationalization with i18next
On one of my projects at work, we used i18next library (along with react-i18next) to manage internationalization in a React application. To implement it, it is usually necessary to do this:
Define an
i18next
instance- Language
- JavaScript
- File
- src/i18n/index.js
import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import { en, ru, // ... other locales } from "./locales"; const resources = { en, ru, // ... other locales }; i18n .use(LanguageDetector) .use(initReactI18next) .init({ detection: { lookupQuerystring: "lng", order: ["querystring"], }, fallbackLng: "en", interpolation: { // Not needed for React escapeValue: false, }, resources, }); export default i18n;
Define the resources
- Language
- JavaScript
- File
- src/i18n/locales/index.js
export { en } from "./en"; export { ru } from "./ru"; // ... other locales
- Language
- JavaScript
- File
- src/i18n/locales/en/index.js
import errors from "./errors.json"; // ... other namespaces const en = { errors, // ... other namespaces }; export { en };
- Language
- JSON
- File
- src/i18n/locales/en/errors.json
{ "validation": { "field": { "is": { "empty": "This field is required", "incorrect": { "password": "Password is incorrect" }, "invalid": { "common": "Please enter a valid value", "email": "Please enter a valid email address" } }, "length": { "lessThan": "This field should contain at least characters", "moreThan": "This field should contain no more than characters" } }, "fields": { "are": { "not": { "equal": { "password": "Passwords don't match" } } } } } }
- Language
- JavaScript
- File
- src/i18n/locales/ru/index.js
import errors from "./errors.json"; // ... other namespaces const ru = { errors, // ... other namespaces }; export { ru };
- Language
- JSON
- File
- src/i18n/locales/ru/errors.json
{ "validation": { "field": { "is": { "empty": "Это поле является обязательным для заполнения", "incorrect": { "password": "Неверный пароль" }, "invalid": { "common": "Пожалуйста, введите корректное значение", "email": "Пожалуйста, введите корректный адрес электронной почты" } }, "length": { "lessThan": "Это поле должно состоять минимум из символов", "moreThan": "Это поле должно состоять максимум из символов" } }, "fields": { "are": { "not": { "equal": { "password": "Пароли не совпадают" } } } } } }
Import the
i18n
instance- Language
- JavaScript
- File
- src/main.jsx
import * as React from "react"; import ReactDOM from "react-dom"; import "./i18n"; import { App } from "./app"; ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById("root"), );
Pass the translation function to a component via special HOC/React Hook
- Language
- JavaScript
- File
- src/components/app.jsx
import * as React from "react"; // The HOC import { withTranslation } from "react-i18next"; const App = ({ t }) => { /* "This field should contain at least 10 characters" for English. "Это поле должно состоять минимум из 10 символов" for Russian. */ const translatedText = t("errors:validation.field.length.lessThan", { minLength: 10, }); return <h1>{translatedText}</h1>; }; const AppWithTranslation = withTranslation()(App); export { AppWithTranslation as App };
Or
- Language
- JavaScript
- File
- src/components/app.jsx
import * as React from "react"; // The React Hook import { useTranslation } from "react-i18next"; const App = () => { const { t } = useTranslation(); /* "This field should contain at least 10 characters" for English. "Это поле должно состоять минимум из 10 символов" for Russian. */ const translatedText = t("errors:validation.field.length.lessThan", { minLength: 10, }); return <h1>{translatedText}</h1>; }; export { App };
Either way, the component gets the t
function. The function accepts a path to a localization string (see JSON files above), and an object with variables, which are put in the slots (defined in double curly braces) in the string.
The problems
The whole approach looks reasonable, but there are some problems with it:
- Path to a localization string is a plain string, so it is easy to make typos. It is also easy to forget to define a namespace or make typos in it as well. It is necessary to thoroughly check each part of the path to make sure it leads to a correct localization string.
- The packages are heavy: the described approach in total takes ~55.1 KiB, ~51 KiB of which is the packages' code. Check out "Measuring application sizes" section below for more information.
- It is hard to keep the files' structure synchronized between locales. A sub-tree can be missed in a resource, and nothing will tell about that until missing keys feature will handle it (if enabled).
We wrote that application using JavaScript back then. After several years I learned TypeScript and realized, how to solve all these problems.
The solution
I like the API of the t
function (path to a localization string and a bunch of variables to "hydrate" it), so I decided to copy it in a standalone package, that is known as "hydrate‑text". Now let's implement our own i18n solution with it.
Define supported languages and a default one
- Language
- TypeScript
- File
- src/i18n/constants.ts
const SUPPORTED_LANGUAGES_MAP = { en: "en", ru: "ru", // ... other languages } as const; const DEFAULT_SELECTED_LANGUAGE = SUPPORTED_LANGUAGES_MAP.en; export { DEFAULT_SELECTED_LANGUAGE, SUPPORTED_LANGUAGES_MAP };
Define a "dictionary" (a structure, that will be used to type checking our localization resources)
- Language
- TypeScript
- File
- src/i18n/dictionary/index.ts
import { errors } from "./errors"; // ... other namespaces const dictionary = { errors, // ... other namespaces } as const; export { dictionary };
- Language
- TypeScript
- File
- src/i18n/dictionary/errors.ts
const errors = { validation: { field: { is: { empty: "errors.validation.field.is.empty", incorrect: { password: "errors.validation.field.is.incorrect.password", }, invalid: { common: "errors.validation.field.is.invalid.common", email: "errors.validation.field.is.invalid.email", }, }, length: { lessThan: "errors.validation.field.length.lessThan", moreThan: "errors.validation.field.length.moreThan", }, }, fields: { are: { not: { equal: { password: "errors.validation.fields.are.not.equal.password", }, }, }, }, }, } as const; export { errors };
as const
expressions require all the resources to strictly follow the structure.Note: it can be hard to fill in these strings manually, so I just assign empty strings to them and pass the whole structure to this function below.
- Language
- JavaScript
const fillTreePaths = (treeWithEmptyPaths, namespace = "") => { const traverse = (currentTree, parentPath) => Object.entries(currentTree).reduce((updatedCurrentTree, [key, value]) => { const currentPath = `${parentPath}.${key}`; if (typeof value === "string") { return { ...updatedCurrentTree, [key]: currentPath, }; } return { ...updatedCurrentTree, [key]: traverse(value, currentPath), }; }, {}); const newTree = traverse(treeWithEmptyPaths, namespace); const stringifiedTree = JSON.stringify(newTree, null, 2); const refinedTree = stringifiedTree // Removes double quote before keys. .replace(/(\s{2,})"/g, "$1") // Removes double quote right after keys and before colons. .replace(/"(: )/g, "$1"); return refinedTree; }; /* Returns { validation: { field: { is: { empty: "errors.validation.field.is.empty", incorrect: { password: "errors.validation.field.is.incorrect.password", }, invalid: { common: "errors.validation.field.is.invalid.common", email: "errors.validation.field.is.invalid.email", }, }, length: { lessThan: "errors.validation.field.length.lessThan", moreThan: "errors.validation.field.length.moreThan", }, }, fields: { are: { not: { equal: { password: "errors.validation.fields.are.not.equal.password", }, }, }, }, }, } */ fillTreePaths( { validation: { field: { is: { empty: "", incorrect: { password: "", }, invalid: { common: "", email: "", }, }, length: { lessThan: "", moreThan: "", }, }, fields: { are: { not: { equal: { password: "", }, }, }, }, }, }, "errors", );
Define types for the resources
- Language
- TypeScript
- File
- src/i18n/types.ts
import { SUPPORTED_LANGUAGES_MAP } from "./constants"; import { dictionary } from "./dictionary"; type Keys<Type> = keyof Type; type Values<Type> = Type[Keys<Type>]; /* Turns particular string values into abstract 'string' type. It allows to type translations with I18nPaths subtypes, because otherwise TypeScript will be complaining that values of translations are not the same as paths ones. */ type GetDictionary<Type> = Type extends string ? string : { [Path in Keys<Type>]: GetDictionary<Type[Path]> }; type Dictionary = GetDictionary<typeof dictionary>; type SupportedLanguage = Values<typeof SUPPORTED_LANGUAGES_MAP>; type Dictionaries = { [Language in SupportedLanguage]: Dictionary; }; export type { Dictionaries, Dictionary, SupportedLanguage };
Define the resources (I called them
dictionaries
for consistency) for the supported languages- Language
- TypeScript
- File
- src/i18n/dictionaries/index.ts
import { Dictionaries } from "../types"; import { englishDictionary as en } from "./en"; import { russianDictionary as ru } from "./ru"; // ... other dictionaries const dictionaries: Dictionaries = { en, ru, // ... other dictionaries }; export { dictionaries };
- Language
- TypeScript
- File
- src/i18n/dictionaries/en/index.ts
import { Dictionary } from "i18n/types"; import { errors } from "./errors"; // ... other namespaces const englishDictionary: Dictionary = { errors, // ... other namespaces }; export { englishDictionary };
- Language
- TypeScript
- File
- src/i18n/dictionaries/en/errors.ts
import { Dictionary } from "i18n/types"; const errors: Dictionary["errors"] = { validation: { field: { is: { empty: "This field is required", incorrect: { password: "Password is incorrect", }, invalid: { common: "Please enter a valid value", email: "Please enter a valid email address", }, }, length: { lessThan: "This field should contain at least {minLength} characters", moreThan: `This field should contain no more than {maxLength} characters`, }, }, fields: { are: { not: { equal: { password: "Passwords don't match", }, }, }, }, }, }; export { errors };
- Language
- TypeScript
- File
- src/i18n/dictionaries/ru/index.ts
import { Dictionary } from "i18n/types"; import { errors } from "./errors"; // ... other namespaces const russianDictionary: Dictionary = { errors, // ... other namespaces }; export { russianDictionary };
- Language
- TypeScript
- File
- src/i18n/dictionaries/ru/errors.ts
import { Dictionary } from "i18n/types"; const errors: Dictionary["errors"] = { validation: { field: { is: { empty: "Это поле является обязательным для заполнения", incorrect: { password: "Неверный пароль", }, invalid: { common: "Пожалуйста, введите корректное значение", email: "Пожалуйста, введите корректный адрес электронной почты", }, }, length: { lessThan: "Это поле должно состоять минимум из {minLength} символов", moreThan: "Это поле должно состоять максимум из {maxLength} символов", }, }, fields: { are: { not: { equal: { password: "Пароли не совпадают", }, }, }, }, }, }; export { errors };
Create i18n context, context provider and React Hook
- Language
- TypeScript
- File
- src/i18n/context.tsx
import { HydrateText } from "hydrate-text"; import * as React from "react"; import { DEFAULT_SELECTED_LANGUAGE } from "./constants"; import { SupportedLanguage } from "./types"; import { isSupportedLanguage, textResolver } from "./utils"; import { dictionaries } from "./dictionaries"; interface I18nContextInterface { selectedLanguage: SupportedLanguage; setLanguage: (newSelectedLanguage: SupportedLanguage) => void; translate: HydrateText; } const I18nContext = React.createContext<I18nContextInterface>({ selectedLanguage: DEFAULT_SELECTED_LANGUAGE, setLanguage: () => { // no-op }, translate: () => "", }); const I18nProvider: React.FC = ({ children }) => { const [selectedLanguage, setLanguage] = React.useState<SupportedLanguage>( DEFAULT_SELECTED_LANGUAGE, ); React.useEffect(() => { const searchParams = new URLSearchParams(window.location.search); const language = searchParams.get("lng"); if (isSupportedLanguage(language)) { setLanguage(language); } }, []); const translate = React.useMemo( () => textResolver(dictionaries[selectedLanguage]), [selectedLanguage], ); const value = { selectedLanguage, setLanguage, translate, }; return ( <I18nContext.Provider value={value}>{children}</I18nContext.Provider> ); }; const useI18n = (): I18nContextInterface => { return React.useContext(I18nContext); }; export { I18nContext, I18nProvider, useI18n };
- Language
- TypeScript
- File
- src/i18n/utils.ts
import { HydrateText, hydrateText } from "hydrate-text"; import get from "lodash/get"; import { SUPPORTED_LANGUAGES_MAP } from "./constants"; import { Dictionary, SupportedLanguage } from "./types"; const textResolver = (dictionary: Dictionary): HydrateText => (pathOrText, variables, interpolationOptions) => { return hydrateText( get(dictionary, pathOrText, pathOrText), variables, interpolationOptions, ); }; const isSupportedLanguage = ( language: string | null, ): language is SupportedLanguage => { return Object.values(SUPPORTED_LANGUAGES_MAP).includes( language as SupportedLanguage, ); }; export { textResolver, isSupportedLanguage };
Wrap the application in the context provider
- Language
- TypeScript
- File
- src/main.tsx
import * as React from "react"; import ReactDOM from "react-dom"; import { I18nProvider } from "i18n"; import { App } from "./app"; ReactDOM.render( <React.StrictMode> <I18nProvider> <App /> </I18nProvider> </React.StrictMode>, document.getElementById("root"), );
Use the React Hook to get the translation function
- Language
- TypeScript
- File
- src/app.tsx
import * as React from "react"; import { dictionary, useI18n } from "i18n"; const { field } = dictionary.errors.validation; const App: React.VFC = () => { const { translate } = useI18n(); /* "This field should contain at least 10 characters" for English. "Это поле должно состоять минимум из 10 символов" for Russian. */ const translatedText = translate(field.length.lessThan, { minLength: 10, }); return <h1>{translatedText}</h1>; }; export { App };
Now all the problems are solved:
- The path to a localization string is not a plain string now, but a structure, that won't let you make a typo.
- The described approach adds ~8.3 KiB to the bundle (the heaviest part is
get
function from Lodash, that takes ~6.8 KiB). It is a way smaller than the previous one, and it is fully customizable on any level. Check out "Measuring application sizes" section below for more information. Dictionary
type guarantees, that all resources (dictionaries) have the same structure.
Keeping text organized
Even if localization is not necessary, I believe it is still a good idea to keep text organized. In this case, the following approach can be used:
Define a text constant
- Language
- TypeScript
- File
- src/constants/text/errors.ts
const ERRORS_TEXT = { validation: { field: { is: { empty: "This field is required", incorrect: { password: "Password is incorrect", }, invalid: { common: "Please enter a valid value", email: "Please enter a valid email address", }, }, length: { lessThan: "This field should contain at least {minLength} characters", moreThan: `This field should contain no more than {maxLength} characters`, }, }, fields: { are: { not: { equal: { password: "Passwords don't match", }, }, }, }, }, };
Use
hydrate‑text
to provide the text with variables- Language
- TypeScript
- File
- src/app.tsx
import { hydrateText } from "hydrate-text"; import * as React from "react"; import { ERRORS_TEXT } from "constants/text/errors"; const { field } = ERRORS_TEXT.validation; const App: React.VFC = () => { /* "This field should contain at least 10 characters". */ const text = hydrateText(field.length.lessThan, { minLength: 10, }); return <h1>{text}</h1>; }; export { App };
Replacing route variables
In some cases, it is necessary to provide React Router routes with variables, like this:
- Language
- TypeScript
// Given
"/posts/:id";
// Needed (for example, `id` is 10)
"/posts/10";
To achieve this, an ability to replace default variable markers was added. In the source code it is called "interpolation options" (this name was taken from i18next
"Interpolation" page):
- Language
- TypeScript
// "/posts/10"
hydrateText(
"/posts/:id",
{ id: 10 },
{
prefix: ":",
suffix: "",
},
);
If it is necessary to do this in several places, it is better to use another function from hydrate‑text
- configureHydrateText
, which will return hydrateText
function bound to the chosen variable markers:
- Language
- TypeScript
const hydrateRoute = configureHydrateText({
prefix: ":",
suffix: "",
});
// "/posts/10"
hydrateRoute("/posts/:id", { id: 10 });
The markers still can be changed via the third argument, but I can hardly imagine, when it can be useful 🙂
Later on I found a built-in function generatePath, but the examples above are still valid as an illustration of the variable markers changing flexibility.
Measuring application sizes
i18next approach
In each case, check console results and sum up dist/assets/index.<hash>.js
and dist/assets/vendor.<hash>.js
sizes.
- Build the application as is: 1.34 KiB + 181.54 KiB.
- Replace
<App />
bynull
, comment outi18n
andApp
imports and build the application: 0.14 KiB + 127.59 KiB.
The sizes were double-checked with filesize VS Code extension.
We miss withTranslation
and useTranslation
import costs, but I don't think it drastically changes the picture.
hydrate‑text approach
In each case, check console results and sum up dist/assets/index.<hash>.js
and dist/assets/vendor.<hash>.js
sizes.
- Build the application as is: 2.16 KiB + 133.91 KiB.
- Replace
get(dictionary, pathOrText, pathOrText)
bypathOrText
, comment outget
import and build the application: 1.25 KiB + 128.01 KiB. - Replace
<I18nProvider><App /></I18nProvider>
bynull
, comment outI18nProvider
andApp
imports and build the application: 0.14 KiB + 127.59 KiB.
The sizes were double-checked with filesize VS Code extension.