Add i18n support in nest app with nestjs-i18n

Reading Time: 7 minutes

 124 total views,  1 views today

Introduction

Many enterprise applications support multiple languages nowadays to cater to customers whose first language is not English. In nest application, we would like to add i18n support to return i18n messages to client-side applications such that users can respond with appropriate actions. Nest framework does not have i18n out of the box but we can install nestjs-i18n to handle translations and return the i18n texts to the client side.

In this post, we are going to create a new nestjs application and install nestjs-i18n to add i18n support. When request is successful, we perform translation by language and return the i18n string in the response. When request throws exception, we implement a global filter that extracts message key from the error object, translates the message and returns a new error object back to the client-side.

let's go

Create a new nest application

nest new nesti18n-poc

Add i18n support in nest

npm i --save nestjs-i18n

Create an i18n directory under src/ and create en and es folders under src/i18n/. The folder names, en and es, are locales of English and Spanish respectively.

src
├─src/i18n
├── en
│   └── error.json
└── es
    └── error.json

Create JSON translation files

English language, src/i18n/en/error.json

{
    "GOOD_MORNING": "Good Morning",
    "SUBSCRIPTION_EXPIRED": "Contact {0.email} for support",
    "SETUP": {
        "WELCOME": "Welcome {0.username}",
        "BYE": "Bye {0.username}", 
        "TOMATO": { 
            "one": "{0.username} buys {0.count} tomato",
            "other": "{0.username} buys {0.count} tomatoes",
            "zero": "{0.username} buys {0.count} tomato"
        }
    },
    "EXCEPTION": "I don't know {0.language}"
}

Spanish language, src/i18n/es/error.json

{
    "GOOD_MORNING": "Buenos Dias",
    "SUBSCRIPTION_EXPIRED": "Contacto {0.email} para soporte",
    "SETUP": {
        "WELCOME": "Bienvenidos {0.username}",
        "BYE": "Adios {0.username}",
        "TOMATO": { 
            "one": "{0.username} compra {0.count} tomate",
            "other": "{0.username} compra {0.count} tomates",
            "zero": "{0.username} compra {0.count} tomate"
        }
    },
    "EXCEPTION": "No se {0.language}"
}

Copy i18n folder to dist/ in watch mode

Edit nest-cli.json to copy i18n folder to dist/ in watch mode

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      {
        "include": "i18n/**/*",
        "watchAssets": true
      }
    ]
  }
}

Inject I18nModule to enable i18n support

imports: [
    I18nModule.forRoot({
      fallbackLanguage: 'en',
      parser: I18nJsonParser,
      parserOptions: {
        path: path.join(__dirname, '/i18n/'),
        watch: true,
      },
      resolvers: [new HeaderResolver(['language'])],
    }),
],

fallbackLanguage property indicates that the fallback language is English.

parserOptions: {
    path: path.join(__dirname, '/i18n/'),
    watch: true,
}

watch: true flag enables live translation and monitors i18n folder for changes in translations.

resolvers: [new HeaderResolver(['language'])],

looks up language in HTTP request header. If request header specifies language, the language value is used to translate message and return it back to the client. If request header does not contain language, we use the fallback language for translation.

Add i18n support in simple cases

We are going to add new endpoints in AppController to show various i18n examples.

Example 1: Display simple i18n message

@I18nLang decorator extracts the language from request header and passes the value to AppService.

First, we inject I18nService in the constructor of AppService. Then, we call this.i18n.translate('error.GOOD_MORNING', { lang }) to obtain the value of 'GOOD_MORNING' key in error.json. The filename of the translation file is error; therefore, the prefix of the key is error. If the filename of the translation file is payment, the message key will change to payment.GOOD_MORNING.

Next, we use CURL to test the endpoint to obtain i18n messages

curl --location --request GET 'http://localhost:3001/good-morning' \
--header 'language: en'

Response displays

Good Morning

To obtain the Spanish message, we modify the header value to es

curl --location --request GET 'http://localhost:3001/good-morning' \
--header 'language: es'

Response displays

Buenos Dias

Example 2: Replace variable in i18n message by JSON object

In translations files, SUBSCRIPTION_EXPIRED is an i18n message that accepts email variable. As the result, we have to replace the variable with an actual email during translation to get back a meaningful text.

Message in i18n/en/error.json

"SUBSCRIPTION_EXPIRED": "Contact {0.email} for support"

Message in i18n/es/error.json

"SUBSCRIPTION_EXPIRED": "Contacto {0.email} para soporte"

To achieve variable replacement, we pass args that is an optional JSON object into the second argument of this.i18n.translate().

Retrieve the English message for error.SUBSCRIPTION_EXPIRED

curl --location --request GET 'http://localhost:3001/translated-message' \
--header 'language: en'

Response should be

Contact abc@example.com for support

Obtain the Spanish counterpart

curl --location --request GET 'http://localhost:3001/translated-message' \
--header 'language: es'

Response should be

Contacto abc@example.com para soporte

Display nested i18n message

Example 3: Display nested i18n message

Displaying nested message is similar to displaying other messages except the message key has multiple parts separated by periods.

"SETUP": {
        "WELCOME": "Bienvenidos {0.username}",
        "BYE": "Adios {0.username}"
},

In this example, we concatenate the message of error.SETUP.WELCOME and error.SETUP.BYE and return the combined text to the client side.

Similarly, when the structure is nested N levels deep, the message key is in the form of error.<level1>.<level2>.....<levelN>.key

The implementation of getNestedTranslationMessage is identical to getTranslatedMessage except the message keys are error.SETUP.WELCOME and error.SETUP.BYE respectively.

Retrieve the English message

curl --location --request GET 'http://localhost:3001/nested-message?username=John Doe' \
--header 'language: en'

Response should be

Welcome John Doe, Bye John Doe

Retrieve the Spanish message

curl --location --request GET 'http://localhost:3001/nested-message?username=John Doe' \
--header 'language: es'

Response should be

Bienvenidos John Doe, Adios John Doe

Support Pluralization in i18n message

Example 4: Pluralization in i18n message

When a message requires to show count, we want to provide the correct plural form. For example, “0 tomato”, “1 tomato” and “2 tomatoes”.

"SETUP": {
    "TOMATO": { 
         "one": "{0.username} buys {0.count} tomato",
         "other": "{0.username} buys {0.count} tomatoes",
         "zero": "{0.username} buys {0.count}| tomato"
     }
}

When count is 0, nestjs-i18n translates the message of error.SETUP.TOMATO.zero. When count is 1, nestjs-i18n translates the message of error.SETUP.TOMATO.one and the rest uses error.SETUP.TOMATO.other. The keywords, “one”, “other” and “zero”, are mandatory in pluralize translation.

Retrieve the English message when the count of tomato is 0

curl --location --request GET 'http://localhost:3001/pluralize-message?username=John&numOfTimes=0' \
--header 'language: en'

Response should be

John buys 0 tomato

Retrieve the English message when the count of tomato is 1

curl --location --request GET 'http://localhost:3001/pluralize-message?username=John&numOfTimes=1' \
--header 'language: en'

Response should be

John buys 1 tomato

Retrieve the English message when count of tomato is 2

curl --location --request GET 'http://localhost:3001/pluralize-message?username=John&numOfTimes=2' \
--header 'language: en'

Response should be

John buys 2 tomatoes

Spanish messages yield similar results when language header switches to es.

Output when count is 0

curl --location --request GET 'http://localhost:3001/pluralize-message?username=John&numOfTimes=0' \
--header 'language: es'
John compra 0 tomate

Output when count is 1

curl --location --request GET 'http://localhost:3001/pluralize-message?username=John&numOfTimes=1' \
--header 'language: es'
John compra 1 tomate

For count that is greater than 1, the output is

curl --location --request GET 'http://localhost:3001/pluralize-message?username=John&numOfTimes=1' \
--header 'language: es'
John compra 2 tomates

We have wrapped up the happy path where HTTP requests execute successfully and return a response with translated message. The next section describes bad HTTP request where endpoint throws an exception and our global filter intercepts the exception to translate the error message.

Translate HttpException message with i18n support

The first step is to define a global filter that catches all HttpException.

We cast errorResponse as { key: string; args?: Record<string, any> } and call this.i18n.translate(error.key, { lang: ctx.getRequest().i18nLang, args: error.args }) to obtain the translated message. Finally, we call this.httpAdapterHost.httpAdapter.reply(ctx.getResponse(), responseBody, statusCode) to return the formatted error response.

The second step is to register the global filter in AppModule

On the third step, we add “EXCEPTION” key and message in translation files

{
    "EXCEPTION": "I don't know {0.language}"
}
{
    "EXCEPTION": "No se {0.language}"
}

Next, we add a new endpoint that always throws BadRequestException. In the constructor of BadRequestException, the argument is an error object consisted of key and args.

Finally, we can verify the translated exception message with CURL

curl --location --request GET 'http://localhost:3001/bad-translated-exception' \
--header 'language: en'

The error response in English becomes

{
    "statusCode": 400,
    "message": "I don't know German",
    "error": "I don't know German"
}
curl --location --request GET 'http://localhost:3001/bad-translated-exception' \
--header 'language: es'

The error response in Spanish becomes

{
    "statusCode": 400,
    "message": "No se German",
    "error": "No se German"
}

Final thoughts

When a Nest application needs to support i18n, we install nestjs-i18n to send i18n messages and exceptions depending on language. The language can be provided in request header, cookie or GraphQL resolver, or a fallback language is used.

nestjs-i18n supports many use cases: plain message, variable replacement in message, nested message and pluralize translation. Moreover, it is a scalable solution since we can split messages by domains and languages to maintain a hierarchy of JSON files.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular, Nest and other technologies.

Resources:

  1. Github Repository: https://github.com/railsstudent/nesti18n-poc
  2. nestjs-i18n: https://github.com/ToonvanStrijp/nestjs-i18n