In today’s globalized digital world, our applications often need to support multiple languages and regions. Localization means making our app fit different languages and cultures for a good user experience.
However, handling localization can be tricky. It’s easy to mix up variables or use the wrong translation key, and these mistakes often lead to runtime errors or incorrect translations.
TypeScript, with its strong type system, provides an excellent foundation for achieving safer and more efficient localization. In this blog post, we’ll explore how to harness TypeScript’s type system to make localization safer and more robust. You can find the example source code for this post in my GitHub repo.
Before we delve into localization, let’s learn about a closely related concept: Internationalization (i18n).
Internationalization is the process of designing and developing applications in a way that makes them adaptable to various languages and cultures. The term “i18n” is a shorthand notation for “internationalization.” It means “i” followed by 18 letters and then “n.” Similarly, “localization” is often abbreviated as “l10n.”
Localization (l10n) is the process of customizing an application for a particular region or language, which includes translating text, formatting dates and currencies, and making cultural adjustments, such as switching the writing direction.
Think of internationalization as the setup phase that makes our app ready to be adapted to suit different locales. Localization is where we customize our applications to meet the unique needs and preferences of a particular region or language.
Now we understand the basics of i18n and l10n, let’s have a look at how to implement them using i18next.
i18next is a popular open source i18n library for JavaScript that makes it easy to add multilingual support to our applications. It’s designed to help developers manage translations, handle pluralization, and manage the localization of content effectively.
i18next has embedded TypeScript definitions. With type definitions, the TypeScript compiler can detect errors like typos in the translation keys at compile time and reduce the chance of runtime errors. However, there are some limitations to the out-of-the-box features.
We use a contrived example below to demonstrate how to leverage and improve i18next’s existing features to achieve type safety in a TypeScript application.
We start by creating a TypeScript application from scratch:
// Create a new folder for the app mkdir typescript-localization cd typescript-localizationp // Use npm init to create a package.json file, // follow the prompt and accept the default npm init npm install typescript --save-dev // Create a tsconfig.json file which contains the compiler options. npx tsc --init // Create a TypeScript file index.ts and add following code // index.ts const message: string = 'Hello, World!'; console.log(message);
Next, in the tsconfig.json
file, configure the outDir
and set "module"
to "commonjs"
so that TypeScript compiles our code to JavaScript in the dist
folder:
{ "compilerOptions": { "outDir": "./dist", "module": "commonjs", // ... other options }, // ... }
Add the following script commands to the package.json
:
"scripts": { "build": "tsc", "start": "npm run build && node dist/index.js " },
We can now run the application using the following command:
npm run start
Let’s implement localization with i18next in our new TypeScript app. Install i18next and its necessary dependencies:
npm install i18next @types/i18next --save
Next, create JSON files for the supported languages. For example, create locales/en.json
for English and locales/es.json
for Spanish. These files should contain translation key-value pairs:
// locales/en/common.json { "greetings": "Hello, World!", "welcome": "Welcome, {{name}}!" } // locales/es/common.json { "greetings": "¡Hola, Mundo!", "welcome": "¡Bienvenido a nuestra aplicación!" }
After we create the JSON resource files, we add a new file, i18n.ts
, which configures localization with the i18next library, as below:
// i18n.ts import i18next from 'i18next'; import * as enCommon from './locales/en/common.json'; import * as esCommon from './locales/es/common.json'; export const defaultNS = 'common'; // Default name space i18next.init({ lng: 'en', // Default language fallbackLng: 'en', // Fallback language debug: true, // Enable debug mode (optional) resources: { en: { common: enCommon, }, es: { common: esCommon, }, }, }); export default i18next;
Let’s break down the code above:
i18next.init({ ... });
: This block initializes the i18next library with various configuration optionslng
and fallbackLng
: These options specify the default and fallback languages. In this example, both are set to en
, indicating that both are Englishdebug
: This option is set to true
, which enables debug mode. In debug mode, i18next provides additional information for development purposesresources
: This object defines the available language resources and maps language codes (en
for English and es
for Spanish) to their respective translation filesIn summary, the above code sets up i18next with default settings for two languages, English and Spanish, using JSON files for translation resources. The common
namespace is defined as the default namespace. The i18next instance is configured and available for use in other parts of the application:
To use i18next as we configured it, we can update the index.ts
file as follows:
import i18next from './i18n'; i18next.changeLanguage('en'); const greetingMessage = i18next.t('common:greetings'); const welcomeMessage = i18next.t('common:welcome', { name: 'John' }); console.log('greeting:', greetingMessage); console.log('welcome:', welcomeMessage);
In the above code, we changed the language setting to English (‘en’) using the i18next library. Then, we used i18next to translate a message with the i18next.t
function, which looks up translations using the message key and any optional parameters you set to address dynamic data interpolation.
Run the application:
npm run start
We should see the following console output:
i18next: languageChanged en greeting: Hello, World! welcome: Welcome, John!
We can switch the language to Spanish by modifying the line "i18next.changeLanguage('en');"
to the following:
i18next.changeLanguage('es');
The console output will be similar to the one below:
i18next: languageChanged es greeting: ¡Hola, Mundo! welcome: ¡Bienvenido a nuestra aplicación!
This means our i18next localization works for our app! However, it isn’t type-safe. For example, if I made a mistake by entering an incorrect translation key, as below:
const greetingMessage = i18next.t('common:wrongkey');
We won’t get any errors or warnings at compile time. When I run the app, I’ll get the following runtime error:
i18next::translator: missingKey es common wrongkey wrongkey greeting: wrongkey
To solve this issue, we can implement a TypeScript definition for our app.
We can expand TypeScript definitions for i18next through type augmentation and interface merging. To start, create a declaration file named i18next.d.ts
under the @types
folder:
import { defaultNS } from '../i18n'; import * as enCommon from '../locales/en/common.json'; declare module 'i18next' { interface CustomTypeOptions { defaultNS: typeof defaultNS; resources: { common: typeof enCommon } } }
The above type definition file extends the module i18next
to include an additional CustomTypeOptions
interface.
We also need to make sure the 'compilerOptions'
section in the tsconfig.json
file has either the strict
flag or that "strictNullChecks"
is set to true
:
"strictNullChecks": false
With the extension of our type definitions, the TypeScript compiler type checking works now.
If I enter an incorrect translation key, the compiler will throw the following error:
If we try to compile and run the application, it will throw the same error:
Despite the improvements we made above, type checking has a limitation: there isn’t a way to type check the automatic interpolation inference.
For instance, if I enter an incorrect string interpolation key, such as wrongname
instead of name
, there won’t be any compile time errors:
const welcomeMessage = i18next.t('common:welcome', { wrongname: 'John' });
If I run the application, the output will look like the below:
welcome: Welcome, {{name}}!
Obviously, i18next can’t handle the incorrect key gracefully.
There are two approaches we can choose between to improve the interpolation’s type safety:
as const
keywordas const
keywordIn TypeScript, we use as const
to create a literal type that’s as narrow as possible.
We can use as const
to convert the JSON resource file to a TypeScript file and extract the type out of the newly created TypeScript constant. We can simply rename the resource file, i.e., from common.json
to common.ts
, and change the first line to export const common =
and the last line to as const
.
Here is the result:
export const common = { "greetings": "Hello, World!", "welcome": "Welcome, {{name}}!" } as const;
Then, we update our type definition file as follows:
import { defaultNS } from '../i18n'; // Import common from the TypeScript constant import { common } from '../locales/en/common'; declare module 'i18next' { interface CustomTypeOptions { defaultNS: typeof defaultNS; resources: { // Extract the type out of the literal type common: typeof common, } } }
The extracted common
type is like the below:
(property) common: { readonly greetings: "Hello, World!"; readonly welcome: "Welcome, {{name}}!"; }
We also need to update the i18n.ts
file to update the reference to the new common.ts
file instead of the common.json
file.
Now, if we enter a wrong key in the interpolation, the TypeScript compiler will throw an error:
The benefit of this approach is that the type is directly extracted from the common.ts
file, and thus we don’t need to maintain another, separate TypeScript file. If there is a change in the common.ts
file (i.e., we added a new translation key-value pair), the change will be automatically reflected in the extracted common
type.
Another way to achieve the additional type safety is to generate an interface file using the i18next-resources-for-ts package
. This library is designed to transform resources to be used in a typesafe i18next project.
To start, install it using the following command:
npm install i18next-resources-for-ts -g
Then, we can generate an interface file from the JSON resource files using the following command. Note that the command is run from the project’s root directory:
i18next-resources-for-ts interface -i ./locales/en/ -o ./@types/
We generate a resources.d.ts
file as below. It contains a TypeScript interface based on the resource JSON file:
// resources.d.ts interface Resources { "common": { "greetings": "Hello, World!", "welcome": "Welcome, {{name}}!" } } export default Resources;
Then, we update the type definition file to reference the new interface:
// @types/i18next.d.ts import { defaultNS } from '../i18n'; import Resources from './resources'; declare module 'i18next' { interface CustomTypeOptions { defaultNS: typeof defaultNS; resources: Resources } }
That’s it! We achieve the same level of type safety as the first approach. If we have a typo for the string interpolation key, the TypeScript compiler will immediately throw an error, and the compile will fail.
However, this approach will require us to maintain both a resource file and an interface file. Thus, we need to re-generate the interface file for every resource file change. Therefore, the as const
approach is a better option from a maintenance perspective.
In this article, we create a contrived TypeScript example with a setup of i18next with default settings for two languages, English and Spanish, and used JSON files for translation data. We also implement the type definition file to add compile-time type checking and further improve type safety by converting the JSON file to TypeScript constants or using a generated interface.
i18next and TypeScript complement each other well. TypeScript’s strong typing and type definitions enhance the development experience, making it easier to manage translations and ensure type safety, ultimately resulting in more robust and maintainable multilingual applications.
You can find the example source code from the GitHub repo.
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.