Internationalize your Next application with i18n and TypeScript
๐ Introduction
When aiming for the global market, any company faces the issue of adapting an application to the language of its potential consumers. In this case, internationalization is not only a technical issue, but also an important business concern.
Internationalization, abbreviated i18n, is the process by which developers prepare software applications to support different languages. The act of Internationalizing does not only involve translations, but also adjusting software to accept different forms of data and settings to match local customs and handle them correctly, such as text direction, localDates, pluralization, currency...
Internationalization vs Localization vs Globalization
Localization (L10n) is the process of adapting a product or service for use in specific countries or regions. It aims to make that product or service understandable and more appealing to potential customers in a certain target market.
The internationalization (L18n) of software consists of preparing it for localization and adaptation to different languages and cultures. Unlike localization, which mainly requires language skills, internationalization is mostly technical work, carried out by programmers.
Internationalization does not only require localization work. It is also to configure colors, font sizes and text direction.
Globalization (G11n) therefore describes keeping the door open for your application, products, and services to be adapted to a worldwide audience. And this is just the starting point. Proper globalization further requires internationalization and localization.
Fun fact: ๐คฃ๐คฃ
The secret behind every termโs abbreviation is that, in English, for Internationalization, there are 18 letters in the word, between i and n. Likewise for localization with L10n. And the same for Globalization G11.
๐๏ธ Goal
For the purpose of translating and internationalizing an application, we can use any vendors/frameworks, but we also need to consider the experience of the developer, such as the difficulty of doing things with this framework.
If we talk about code reliability, readability and stability, we also have to talk about the amount of bugs we can produce while coding.
So how can we ensure that we can implement a solution with best practices and avoid all common potential bugs?
When working with the i18n, one of the hardest times we find is miswriting a long key, or it's always hard to remember when they're long.
In this article, let's try to avoid all these problems using i18next Framework.
๐ Which library should I choose?
In the front end, there are libraries for different stacks such as React, Angular, Vue, etc. Letโs focus here on Next/React, but the fundamental concepts can be applied to any other stack.
To implement i18n, we have many libraries. Among the most popular are:
- React-intl : maintained by Yahoo before, now by formatJs ;
- I18next: by i18next Organization ;
- jQuery.I18n: hosted by Wikimedia group ;
- Globalize: by GlobaleJs ;
- Polyglot: by Airbnb Engineers .
Why i18next?
After a bit of research, i18next seemed much easier to use and can be used not only for react applications, but also for many other frameworks like Angular, Vue, Vanilla, JavaScript and NodeJS.
Let me convince you too:
Sustainability/Maintainability
Looking at their repositories, only 8 issues since 2011 are still open, over 1000 issues closed and over 580 forks.
Popularity
i18next is the most downloaded library with an average of 2,8M weekly downloads on npm repository.
Adaptation
With i18next v2 rebuild, it can be used in a wide variety of contexts:
- Any javascript (and a few non-javascript - .net, elm, iOS, android, ...) environment;
- Any UI framework;
- Any i18n format.
Have a look at what the community built around the i18next core:
Richness
Every i18n frameworks work with the same pattern: import all your translations and the language used, and call a function to return the correct translation.
i18next provides you with a bunch of plugins such as:
- Languages-detector and Code-Scanner;
- Split translations into multiple files/namespaces. Load only necessary translations;
- Bunch of plugins to detect languages for each environments (browser, native, server);
- Options what to load and how to fallback depending on language;
- Support for nested objects and arrays ;
- Freedom of i18n plugins - prefer React or ICU? Just use i18next-icu or react-18next plugin;
- ...
๐ Tutorial & best practices
In this article, let's focus on the useful part, by creating a small internationalized application with nextJs that has a language detector using i18next.
To do this, create a next application. With TypeScript, it makes it easy to define prop types, which makes code much easier to read and use. And that will come with support for IntelliSense and static type checking.
Read more why is it recommended to use typescript in React projects.
Initiating the project
The easiest and recommended way to get started is to use create-next-app usin npm or yarn:
yarn create next-app --typescript {your-app-name}
cd {your-app-name}
yarn dev
This will create a bunch of boilerplate files which help you get started, including a basic .eslint config.
Reminder 1 : React does not include internationalization (i18n), but it is not difficult to internationalize an application, especially with the help of i18next.
Reminder 2 : i18next is an i18n framework written in and for JavaScript. It provides the standard i18n features: interpolation, formatting, plural and context management. But nothing is planned for React.
Fortunately : the i18next organization has developed a set of plugins, such as react-i18next which provides React utilities such as hooks, components and Providers.
Letโs add i18next dependencies:
yarn add i18next react-i18next
yarn add i18next-browser-languagedetector
Note that i18next-browser-languagedetector is a i18next language detection plugin used to detect user language in the browser with support for:
- cookie (set cookie i18next=LANGUAGE) ;
- sessionStorage (set key i18nextLng=LANGUAGE) ;
- localStorage (set key i18nextLng=LANGUAGE) ;
- Navigator (set browser language) ;
- ....
๐ File structure
In this article, we follow a modular architecture, if we agree on the following schema:
The translation directory contains all the modules
of the project.
Each module contains localisation files with the [lang].json
form and an index.ts
to export the module content.
๐ก [lang
] corresponding to the Language tags, if you don't know how to choose the right one, read the section Choosing a language tag.
Each module contains the keys related only to this module. Once we have a module enclosing a subject, we can consider that this module is a NameSpace.
Note that one of the best features of i18next is that it supports NameSpaces.
While in a smaller project it might be reasonable to just put everything in one file, you might get at a point where you want to break translations into multiple files. Reasons might be:
- You start losing the overview having more than 300 segments in a file;
- Not every translation needs to be loaded on the first page, speed up load time.
This architecture will allow us to maintain and manage large projects and to reuse translation keys.
Note that i18n requires a JavaScript Object, so you are free to use JSON or YAML or any other format to describe your localization files while you can convert them to a js Objects.
For the purposes of this article, and because we are using Next/React, it is recommended to use JSON files.
Also note that by using the ES6 import statement with the json-loader module, any JSON file can be consumed in the React app as Javascript Object. Since we are using create-next-app to scaffold our project, the module is already included, you just need to import your JSON as:
import data from 'path/to/file.json'
Let's take look at the auth module.
//en.json
{
"signin": {
"hello": "Hello {{user}}",
"signinButton": "Sign In",
"term&policy": "By clicking sign In, you accept our <signed> Terms. <signed>",
"hint": "If you already have an account, create one",
"header": "Welcome to <Link> typed-i18next </Link>"
}
}
//fr.json
{
"signin": {
"hello": "Bonjour {{user}}",
"signinButton": "Connexion",
"term&policy": "En cliquant sur Connexion, vous acceptez <signed> nos Conditions. <signed>",
"hint": "Si vous avez dรฉjร un compte, crรฉez-en un",
"header": "Bienvenue ร <Link> typed-i18next </Link>"
}
}
๐ก [Good practice] It is recommended to have a common module that contains shared translations between modules, when we have shared keys or independents, we join them all in this namespace.
The index file of each module contains an export of all [lang].json
files
import en from "./en.json";
import fr from "./fr.json";
//import all localization files here
export { en, fr };
๐ Create a central resource for i18next
i18next accepts only one resource entry, so to provide i18next through all locale files, these modules must be grouped as NamesSpaces in a global resource.
In the index file of the translations folder, we will group everything together.
import * as common from "./common";
import * as auth from "./auth";
const loadedNameSpaces = {
common,
auth,
// .... add your modules
};
export type NameSpace = keyof typeof loadedNameSpaces;
export const defaultNameSpace: NameSpace = "common";
type SupportedLocale = "en" | "fr";
export const defaultLanguage: SupportedLocale = "fr";
export const keySeparator = "."; // example key :`signin.signinButton`
i18next loads its own resources as object of NameSpaces, if you don't want to use any NameSpaces, i18next sets common as the default NameSpace. The form of resources used by i18next is defined as follows:
interface I18nResource{
[lang: string]: {
[namespace: string]: {
[key: string]: string | JsonObject;
};
};
};
On the other hand, loadedNameSpaces
which contains all loaded namespaces/modules is typed by default as:
interface LoadedNameSpaces{
[namespace: string]: {
[lang: string]: {
[key: string]: string | JsonObject;
};
};
}
ย ๐ก ย Challenge : how to convert/tranform loadedNameSpaces to Resource
object ?
With a workaround, trying to swap the locations of [lang]
and [namespace]
,wrapping the namespace with language instead, we can type and transfer our loaded NameSpaces into this form using Lodash
install lodash using
yarn add lodash
Let's update our translations/index.ts and create a function to do so, call it adaptLoadedResources
in order to obtain something similar to:
import _ from "lodash";
import * as common from "./common";
import * as auth from "./auth";
const loadedNameSpaces = {
common,
auth,
// .... add your modules
};
export type NameSpace = keyof typeof loadedNameSpaces;
export const defaultNameSpace: NameSpace = "common";
type SupportedLocale = "en" | "fr";
export const defaultLanguage: SupportedLocale = "fr";
export const keySeparator = ".";
type LoadedResources = { [locale in SupportedLocale]: Translation };
type Translation = { [key: string]: string | Translation };
type Translations = {
[nameSpace in NameSpace]: Translation;
};
type I18nResource = {
[locale in SupportedLocale]: Translations;
};
function adaptLoadedResources() {
const flatNameSpaces = Object.entries(loadedNameSpaces) as [
NameSpace,
LoadedResources
][];
const flatResources = flatNameSpaces.map((nameSpace) => {
const locales = Object.entries(nameSpace[1]) as [
SupportedLocale,
Translation
][];
return locales.reduce<I18nResource>((accumulator, locale) => {
accumulator[locale[0]] = {
[`${nameSpace[0]}`]: locale[1],
} as Translations;
return accumulator;
}, {} as I18nResource);
});
return _.spread(_.partial(_.merge, {}))(flatResources);
}
export const resources: I18nResource = adaptLoadedResources();
Since we type everything, why can't we go further and type namespaces, resources, even translation keys?
Here we go:
......
// loadedNameSpaces ={common,auth} ....
// add this
type NameSpace = keyof typeof loadedNameSpaces;
type AllLoadedNameSpaceType = typeof loadedNameSpaces[NameSpace];
type AllLoadedNameSpaceTypeByLanguage =
AllLoadedNameSpaceType[typeof defaultLanguage];
type UnionToIntersection<U> = (
U extends unknown ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never;
export type RecursiveKeyOf<TObj extends Record<string, unknown>> = {
[TKey in keyof TObj & (string | number)]: TObj[TKey] extends unknown[]
? `${TKey}`
: TObj[TKey] extends Record<string, unknown>
? `${TKey}${typeof keySeparator}${RecursiveKeyOf<TObj[TKey]>}`
: `${TKey}`;
}[keyof TObj & (string | number)];
type FlattenTypedKey = UnionToIntersection<AllLoadedNameSpaceTypeByLanguage>;
// to type translation keys
export type TranslationKey = RecursiveKeyOf<FlattenTypedKey>;
export const nameSpaceNames = Object.keys(loadedNameSpaces) as NameSpace[];
// create an instance for nameSpace which contains keys as values, to simplify accessibility to nameSpaces
// this variable is used everywhere when we need to call a translation from a specific nameSpace
export const nameSpaces: Record<NameSpace, NameSpace> = nameSpaceNames.reduce(
(record, ns) => Object.assign(record, { [ns]: ns }),
{} as Record<NameSpace, NameSpace>
);
Magical, isn't it? With this, we avoid miswriting the nameSpace/translation keys. And this is the most important part of this article.
Notes:
- We export resources, defaultLanguage, defaultNameSpace, keySeparator, nameSpaceNames to initialize i18next , and NameSpace, TranslationKey, nameSpaces, to use them around the application;
- TranslationKey is a type that contains all possible key combinations splitted by
KeySeparator
; - nameSpaces is an instance of Record <NameSpace, NameSpace>, dynamically generated from the set of declared modules, in order to obtain an object generated and shared from the loaded resource:
export const nameSpaces = {
common:'common',
auth:'auth',
// ... the rest of namespaces
}
๐ i18next Initialization
Creating an i18next initialization service under ~/services/localization/i18n
import LanguageDetector from "i18next-browser-languagedetector";
import i18n, { i18n as i18nApi } from "i18next";
import { initReactI18next } from "react-i18next";
import {
defaultLanguage,
defaultNameSpace,
keySeparator,
nameSpaceNames,
resources,
} from "~/translations";
export function initI18n(locale?: string): i18nApi {
i18n
.use(initReactI18next)
.use(LanguageDetector)
.init({
ns: nameSpaceNames,
defaultNS: defaultNameSpace,
lng: locale,
resources,
// if you don't have plural polyfill installed already
compatibilityJSON: "v3",
fallbackLng: defaultLanguage,
keySeparator: keySeparator,
// optional : used to avoid character codes in texts -> user values have to be escaped manually to mitigate XSS attacks
interpolation: { escapeValue: false },
});
return i18n;
}
Reminder : LanguageDetector is a module that detects the language of your machine, you can configure it on other config.
LanguageDetector has no effect if we provide a correct locale. So, if we want to use a specific language, we have to initialize it with the corresponding locale.
About to finish. I just need to add the service in App.tsx.
import React from "react";
import './App.css';
initI18n(); // here ๐
const App = () => {
return (
<div className="App">
Hello Nimbler
</div>
);
}
export default App;
Note that react-i18next is Provider pattern friendly, you may be at a stage where you are drilling through many layers of components while building your application. Use I18nextProvider if that so.
More about about props-drilling .
๐Manual and uses
Now it only remains to use these resources with the react-i18next providers, hooks, functions and components
Example by using useTranslation
hook and ย Trans
component including some features such as Interpolation and Context:
import React from "react";
import { nameSpaces } from "~/translations";
import { Trans, useTranslation } from "react-i18next";
// random wrapper example
const BoldDecorator = ({ children }: { children: React.ReactNode }) => (
<div className="bold">{children}</div>
);
const NimblerComponent: React.FC = () => {
const { t } = useTranslation(nameSpaces.auth);
return (
<div>
{t("signin.hello", { user: "Nimbler" })} // interpolation
<Trans
i18nKey="signin.term&policy"
ns={nameSpaces.auth}
components={{
signed: BoldDecorator, // signed is a custom tag , choose what you want for that
}}
/>
</div>
);
};
export default NimblerComponent;
3 ways to use the translation
The Hook useTranslate
returns a function of type TFunction
funtion t (key: string, options?: TOptions | string): TFuncReturn;
// use
const {t} = useTranslation(nameSpace?)
options
is an object that can contain configs, nameSpace change, interpolation argument, context ...., Read more.
If you want to translate paragraphs containing DOM - part of the text is wrapped by another component - it's better to use the Trans
component (see the example before).
If you are in a context where you can't use either the component or the Hook (in a function/service), you can always import the t
function from the i18next packages directly:
import i18next from "i18next";
export function getSomeTranslationFromAFunction(option) {
const tOption = i18next.t('option.label', { ns: nameSpaces.common});
return tOption;
}
NOTE: whenever you use hook
, or the t: TFunction
,or Trans component
, you must specify the namespace you want to use, otherwise it uses the default namespace specified in ~/translations/index.ts
ย ( defaultNameSpace ='common'
in our case).
Did you notice that so far we have not exploited any of the types we have prepared? Remember that the purpose of these types is to prepare our IDE's intellisense to suggest and correct us if we insert the wrong key or namespace.
If you notice, all react-i18next components require a string key, and any string will do, and we want them to prompt us for a typed Translationkey
that we generate.
To do this, either we extend our react-i18next components by creating a new declaration file, i.e. creating a react-i18n.d.ts and trying to override the definitions, react-i18next can be extended using Type Augmentation and Merging Interfaces.
import "react-i18next";
import { resources } from "./localization/translations";
declare module "react-i18next" {
type DefaultResources = typeof resources["en"];
interface Resources extends DefaultResources {}
}
// react-i18next versions higher than 11.11.0
declare module "react-i18next" {
interface CustomTypeOptions {
defaultNS: "common";
resources: typeof resources["en"];
}
}
โข๏ธ Important :
This will define the namespace only for components and utilities. To type keys, it is recommended to prepare new HOC and hook and use them instead of using i18next modules directly.
๐ Create your own utilities
Starting by hook : Create a new hook useTypedTranslation
depending on TranslationKey
and NameSpace
that we generated earlier as argument. Then call i18next useTranslation
inside.
import { i18n, TOptions } from "i18next";
import {
Namespace,
TFunction,
useTranslation,
UseTranslationOptions,
UseTranslationResponse,
} from "react-i18next";
import { NameSpace, TranslationKey } from "~/translations";
type TypedNameSpaceOptions = TOptions & { ns?: NameSpace };
type TypedTranslationOptions = string | TypedNameSpaceOptions | undefined;
type TFunctionParams<N extends Namespace> = Parameters<TFunction<N, undefined>>;
type UseTypedTranslationResponse<N extends Namespace> = {
t: (
key: TranslationKey,
options?: TypedTranslationOptions,
defaultValue?: TFunctionParams<N>[1]
) => string;
i18n: i18n;
ready: boolean;
};
export function useTypedTranslation<N extends Namespace>(
ns?: N,
options?: UseTranslationOptions
): UseTypedTranslationResponse<N> {
const response: UseTranslationResponse<NameSpace, undefined> = useTranslation(
ns,
options
);
function _t(
key: TranslationKey,
options?: TypedTranslationOptions,
defaultValue?: TFunctionParams<N>[1]
) {
return response.t(key, defaultValue, options);
}
return { ...response, t: _t };
}
Then do the same thing with TransComponent
import React from "react";
import { Trans } from "react-i18next";
import { NameSpace, TranslationKey } from "~/translations";
interface TypedTransComponentProps {
i18nKey: TranslationKey;
ns: NameSpace;
prefix?: string;
components?: TypedComponents;
}
type TypedComponents =
| React.ReactNode[]
| {
[tagName: string]: React.ReactNode;
bold?: React.ReactNode;
Link?: React.ReactNode;
};
export function TypedTransComponent({
i18nKey,
ns,
components,
prefix,
}: TypedTransComponentProps) {
return (
<Trans ns={ns} prefix={prefix} i18nKey={i18nKey} components={components} />
);
}
Now, we have a perfect hook and component to use.
On this stage , you should have something similar to this:
๐ Conclusion
For someone who uses this technology more often, it's a real disappointment to see the number of bugs and problems I encounter with the key definition in particular, and for someone who works with most of these technologies above, it's really a relief to have a base, a way to work with, an extensible path.
This is an amazing and useful feature that makes the internationalization process much easier. In this article, I have shared a simple proof of concept, and it can be extended using all the features of react-i18next
like namespaces and interpolation.
Source code : https://github.com/Ar-mane/typed-i18next-example
I wonder why the i18n community still hasn't integrated an official way to do this,
there is currently an ongoing pull request to make i18next translation functions fully type-safe: https://github.com/i18next/i18next/pull/1775
It opens a discussion on how to use it to create a shared library, which helps us translate keys for any project, based on this artice.
I hope this topic will be useful for you. Feel free to comment or contact me.