Generate i18n pdf invoice in nestjs app with nestjs-i18n

Reading Time: 7 minutes

 162 total views

Introduction

This project is a proof of concept (POC) to generate i18n pdf invoice using nestjs-i18n and html2pdf. In this use case, the i18n language comes from database instead of http request. It is because scheduled job generates and sends out invoices on a monthly basis that does not involve HTTP communication. After retrieving the language, it is stored in Continuation-Local Storage (CLS) namespace that is available in CLS context. Services can get the language from the context to perform translation and date localization to generate i18n pdf invoice.

let's go

Create a new nest application

nest new nest-pdfmake-poc

Download google font files that support English and Traditional Chinese

Navigate to https://fonts.google.com/noto/specimen/Noto+Sans+TC and download Noto Sans Traditional Chinese font files

Navigate to https://fonts.google.com/specimen/Roboto and download Roboto font files

Copy the font files to src/fonts folder

src/fonts
├── NotoSans
│   ├── NotoSansTC-Bold.otf
│   ├── NotoSansTC-Light.otf
│   ├── NotoSansTC-Medium.otf
│   └── NotoSansTC-Regular.otf
└── Roboto
    ├── Roboto-Italic.ttf
    ├── Roboto-Medium.ttf
    ├── Roboto-MediumItalic.ttf
    └── Roboto-Regular.ttf

Add i18n support in nest

npm i --save nestjs-i18n

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

 i18n
├── en
│   └── invoice.json
└── hk
    └── invoice.json

Create JSON translation files

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

{
  "invoice": {
    "bill_to": "Bill To",
    "date_of_issue": "Date of issue",
    "payment_method": "Payment Method",
    "credit_card": "Credit Card",
    "description": "Description",
    "unit_price": "Unit Price",
    "quantity": "Quantity",
    "total": "Total",
    "total_amount": "Total Amount",
    "title": "Invoice",
    "page_number": "Page {currentPage} of {pageCount}"
  }
}

Chinese language, src/i18n/hk/invoice.json

{
  "invoice": {
    "bill_to": "記賬到",
    "date_of_issue": "簽發日期",
    "payment_method": "付款方法",
    "credit_card": "信用卡",
    "description": "描述",
    "unit_price": "單價",
    "quantity": "數量",
    "total": "金額",
    "total_amount": "總金額",
    "title": "發票",
    "page_number": "第 {currentPage} 頁,共 {pageCount} 頁"
  }
}

Copy i18n and fonts folders to dist/ in watch mode

Edit nest-cli.json to copy i18n and fonts folders to dist/ in watch mode. I18n json and font files are refreshed in dist/ folder without server restart

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      {
        "include": "fonts/**/*",
        "watchAssets": true
      },
      {
        "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,
      },
    }),
],

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.

Add dependencies to the project

Install dependencies to generate i18n pdf invoice

npm i html-to-pdfmake pdfmake jsdom date-fns
npm i --save-dev @types/html-to-pdfmake @types/jsdom @types/pdfmake 

html-to-pdfmake converts Html to pdfmake definition to render Pdf documents. pdfmake library call generate pdf documents on the server or client side while date-fns formats i18n date depending on locale.

Install dependencies of CLS hooked and other libraries

npm i cls-hooked cross-env class-transformer class-validator
npm i --save-exact lowdb@1.0.0 
npm i --save-dev @types/cls-hooked @types/lowdb

cls-hooked allows developers to create CLS namespace to store variables in its CLS context. Any method that executes in the scope of the context can access the variables to perform their own logic until it terminates.

lowdb is a local JSON database that stores user profiles to generate pdf invoice based on the user language

Create user database to generate i18n pdf invoice

Create src/db.json and copy the file to dist/ in watch mode

// src/db.json
{
  "users": [
    {
      "id": 1,
      "name": "John Doe Customer",
      "email": "john.doe@email.com",
      "language": "en"
    },
    {
      "id": 2,
      "name": "Jane Doe Customer",
      "email": "jane.doe@email.com",
      "language": "hk"
    }
  ]
}

nest-cli.json

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      ....
      {
        "include": "db.json",
        "watchAssets": true
      }
    ]
  }
}

When user id is 1, the user language is English. When user id is 2, the user language is Chinese.

Use case of applying i18n translation to invoice and date

The use case is to retrieve user language from database and generate a pdf invoice. When language is “en”, we generate an English invoice with “MMM dd, yyyy” date format. Similarly, user receives Chinese invoice when language is “hk” and the invoice date has Chinese characters to represent year, month and day respectively.

English invoice


Chinese invoice

Architecture of the application

The architecture of the application has two modules: core module that is consisted of core services and invoices module that generates pdf invoice

src
├── core
│   ├── core.module.ts
│   ├── helpers
│   │   ├── cls-hook.helper.ts
│   │   ├── font.helper.ts
│   │   ├── i18n-date.helper.ts
│   │   └── translation.helper.ts
│   └── services
│       ├── date-fns.service.ts
│       ├── pdf.service.ts
│       └── translation.service.ts
├── invoice
│   ├── controllers
│   │   └── invoice.controller.ts
│   ├── invoice.module.ts
│   └── services
│       └── invoice.service.ts
└── main.ts

Responsibility of the components

  • PdfService – A service that creates pdf document and streams to the bytes to client
  • DateFnsService – A service that formats date based on locale
  • TranslationService – A service that accesses language from cls context and translates message keys by language
  • ClsHookHelper – A helper function that creates CLS namespace and sets the user language in the CLS context
  • TranslationHelper – A helper that calls TranslationService to translate message keys
  • FontHelper – A helper that determines the google font to use in pdf generation
  • I18nDateHelper – A helper that uses the user language to return the locale and date format
  • InvoiceController – A controller that routes http request to stream i18n Pdf invoice
  • InvoiceService – A service that calls core services to generate pdf invoice with the correct language

Add PDF service to generate i18n pdf invoice

nest g mo core
nest g s core/services/pdf --flat

In order to inject pdfmake library in PdfService, we have to create a custom pdfmake provider in the module.

export const PDF_MAKER_SYMBOL = Symbol('PDF_MAKER')

Format date by locale and format string

nest g s core/services/dateFns --flat

The DateFnsService service has a formatDate method that formats date object/milliseconds by format string and an optional locale

Add CLS hook helper and translation service

First, create a new CLS namespace to set arbitrary language and execute any callback

Second, use nest-cli to generate a TranslationService

nest g s core/services/translation --flat

TranslationService injects I18nService of nestjs-18n to perform i18n translation.

getCurrentLanguage finds the language in CLS context if it exists. Otherwise, the function returns the fallback language that is ‘en’

translate assigns the current language to i18n option, passes it to i18nService to perform translations.

Add other helpers

Since language is available in CLS context, we can define other helpers.

First, we define translation helper functions that return the user language and translate texts.

Then, we add a font helper function to return google font. The font keys of English and Chinese invoices are Roboto and Noto respectively.

Finally, the date helper provides locale and date format by the user language.

After the implementation of core module, we are ready to generate invoice module to generate i18n pdf invoice by user id

Generate invoice module for pdf generation

Similar to core module, we use nest-cli to generate invoice module, controller and service

nest g mo invoice
nest g co invoice/controllers/invoice --flat
nest g s invoice/services/invoice --flat

After scaffolding the boilerplate codes, we add a POST endpoint to generate invoice by user id

Next, we implement generate that does not exist in InvoiceService.

getUser finds user data in the JSON database by user id

export interface User {
  id: number
  name: string
  email: string
  language: string
}

export interface DbSchema {
  users: User[]
}

getInvoiceHtml is the html codes of the dummy invoice

private getInvoiceTranslatedValues() {
    const [
      billTo,
      dateOfIssue,
      paymentMethod,
      creditCard,
      description,
      unitPrice,
      quantity,
      total,
      totalAmount,
      title,
    ] = translates([
      'invoice.invoice.bill_to',
      'invoice.invoice.date_of_issue',
      'invoice.invoice.payment_method',
      'invoice.invoice.credit_card',
      'invoice.invoice.description',
      'invoice.invoice.unit_price',
      'invoice.invoice.quantity',
      'invoice.invoice.total',
      'invoice.invoice.total_amount',
      'invoice.invoice.title',
    ])

    return {
      billTo,
      dateOfIssue,
      paymentMethod,
      creditCard,
      description,
      unitPrice,
      quantity,
      total,
      totalAmount,
      title,
    }
 }

Test i18n pdf invoice generation

Open a terminal and start nest server in development mode

npm run start:dev

Make a HTTP request to preview English invoice

curl --location --request POST 'http://localhost:3000/invoice/1' \
--data-raw ''

Make a HTTP request to preview Chinese invoice

curl --location --request POST 'http://localhost:3000/invoice/2' \
--data-raw ''

The static texts are in Chinese and the date, 2022年4月17日, is shown in Chinese format where

年 means year
月 means month
日 means day 

This’s it. We have done a POC on i18n pdf invoice in Nest and user language controls what we actually see in the invoice. English invoice has English static texts and date format. Chinese invoice has Chinese static texts and date format.

Final thoughts

Another use case of nestjs-i18n is to render pdf invoice according to user preference. The use case retrieves the language from database and saves the value in a CLS namespace such that it is accessible by services and helpers that run in the CLS context. CLS context makes passing language to services and helpers very convenient and we can expand it to store additional variables when required.

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/nest-pdfmake-poc
  2. cls-hooked: https://github.com/jeff-lewis/cls-hooked
  3. nestjs-i18n: https://github.com/ToonvanStrijp/nestjs-i18n
  4. data-fns: https://date-fns.org/v2.28.0/docs/format
  5. lowdb: https://github.com/typicode/lowdb/tree/v1.0.0

Add i18n translation to emails in nestjs app with nestjs-i18n

Reading Time: 6 minutes

 114 total views

Introduction

This project is a proof of concept (POC) to apply i18n translation to emails. Our vendor has a different solution to translate emails at work; therefore, this solution is not picked up. Nonetheless, I want to show my work in this post to demonstrate how to use nestjs-i18n library to translate the content of Mjml template and render the html email. In development mode, we preview emails of different languages on browser. In production mode, we send test emails to Mailtrap to verify the set up of our email service.

let's go

Create a new nest application

nest new nesti18n-mjml-poc

Add i18n support in nest

npm i --save nestjs-i18n

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

 i18n
├── en
│   └── email.json
├── es
│   └── email.json
└── hk
    └── email.json

Create JSON translation files

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

{
  "welcomeUser": {
    "THANKS_AND_BEST_REGARDS": "Thanks and best regards,",
    "WELCOME": "Welcome to {0.organization}.",
    "MEMBERSHIP_FEE": "Membership fee is {0.total} dollars and starts at {0.startDate}.",
    "TITLE": "Dear {0.name},",
    "ADMIN": "Administrator",
    "SUBJECT": "Registration completed"
  }
}

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

{
  "welcomeUser": {
    "THANKS_AND_BEST_REGARDS": "Gracias y un saludo,",
    "WELCOME": "Bienvenido a {0.organization}.",
    "MEMBERSHIP_FEE": "La cuota de membresía es {0.total} dolares y comienzas en {0.startDate}.",
    "TITLE": "Quierdo {0.name},",
    "ADMIN": "Administrador",
    "SUBJECT": "Registro completado"
  }
}

Chinese language, src/i18n/hk/email.json

{
  "welcomeUser": {
    "THANKS_AND_BEST_REGARDS": "謝謝,",
    "WELCOME": "歡迎來到{0.organization}。",
    "MEMBERSHIP_FEE": "會員費{0.total}港幣和開始於{0.startDate}。",
    "TITLE": "親愛的{0.name},",
    "ADMIN": "管理員",
    "SUBJECT": "註冊完成"
  }
}

Copy i18n folder and mjml files to dist/ in watch mode

Edit nest-cli.json to copy i18n folder and any mjml file to dist/ in watch mode

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      {
        "include": "**/*.mjml",
        "watchAssets": true
      },
      {
        "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.

Install dependencies for apply i18n translation to emails

npm i cross-env lodash mjml nodemailer preview-email
npm i --save-dev @types/lodash @types/mjml @types/nodemailer @types/preview-email
npm i class-validator class-transformer

Use case of apply i18n translation to emails

The use case is to send welcome user email to users according to their language. When language is en, we send English email. Similarly, user receives Spanish email when language is es and Chinese email when language is hk.

English email

Spanish Email

Chinese mail

Architecture of the application

The architecture of the application has two modules: core module that is consisted of core services and user module that sends user emails

Responsibility of the components

  • MjmlService – A service that renders Mjml email template in html format
  • MailserService – A service that previews email on browser in development mode or send email to Mailtrap in production mode
  • AppConfigService – A service that return configuration values of .env file
  • UserController – A controller that routes http request to send welcome user email
  • UserService – A service that calls MjmlService and MailerService to send welcome user email

Add Configurations for the application

First, use nest-cli to generate core module and then AppConfigService service in core/services folder

nest g mo core
nest g s core/services/appConfig --flat

Define configurations of node environment and smtp server in the service

Setup Mjml service to render html email

Next, Use nest cli to generate Mjml Service in the core module

nest g s core/services/mjml --flat

Implement MjmlService

We design the generate template flow as follows:

  1. Load the Mjml template into a string
  2. Use Lodash template to create a compiled template
  3. Compile the template with i18n translations to replace variables in the template
  4. Execute mjml2html to render Mjml template in html format

Create Mailer Service to preview i18n translated emails

Then, use nest cli to generate Mailer Service in the core module

nest g s core/services/mailer --flat

Implement MailerService

In the constructor, we read the from address from the configuration service and create nodemailer smtp transporter.

Define send mail logic

When the application runs in development mode, we preview the email on browser. Otherwise, nodemailer sends the emails with a mail server.

The reason that I choose Mailtrap is the functionality comes out of the box. If I do not use it, I will either create a Docker container with Mailtrap image or configure SMTP server on cloud.

Configure SMTP environment variables

In order to use nodeMailer SMTP transporter to send mail, we have to find a free and safe SMTP server. In this blog post, we choose Mailtrap because it does not require DevOp or networking background to configure a SMTP server.

Finally, we use Mailtrap sandbox to test send email in production mode after development work is done.

First, we navigate to Mailtrap -> Inboxes -> SMTP Settings and select Nodemailer Integrations. Then, we copy SMTP host, SMTP port, SMTP user and SMTP password to .env file

NODE_ENV=development
PORT=3000
MAILER_FROM=no-reply@example.com
SMTP_HOST=smtp.mailtrap.io
SMTP_PORT=2525
SMTP_USER=<mailtrap username>
SMTP_PASSWORD=<mailtrap password>

Implement i18n translation to email in development mode

Step 1 is create a DTO to send welcome user email

import { IsEmail, IsNotEmpty, IsString } from 'class-validator'

export class WelcomeUserDto {
  @IsNotEmpty()
  @IsString()
  organization: string

  @IsNotEmpty()
  @IsString()
  name: string

  @IsNotEmpty()
  @IsEmail()
  email: string
}

Step 2 is create endpoint in user controller to send welcome user email

Inject i18n, Mjml and Mail services in the constructor

Create a helper function that accepts i18n language and arguments, and translates the text

Step 3 is add send welcome user email method in the service

Step 4 is test the endpoint with CURL

Open a terminal and start nest server in development mode

npm run start:dev

Make a HTTP request to preview Spanish email

curl --location --request POST 'http://localhost:3000/user/welcome-useurse ' \
--header 'language: es' \
--header 'Content-Type: application/json' \
--data-raw '{
    "organization": "House of Nest",
    "name": "John Doe",
    "email": "john.doe@example.com"
}'

Repeat i18n translation to emails in production mode

npm run start:production

Since we have set up a running Mailtrap SMTP server, we can mimic the scenario that users receive their mail in inbox from mail provider such as AWS or Google.

As a result, we can call the endpoint to send English, Spanish and Chinese emails to the inbox.

The result is the same as development mode except the destination is mailbox and not the browser.

Up to this point, we have a working solution that is capable of rendering i18n emails in three languages. However, the application can make 2 minor improvements in language extraction and docker.

Improvements

  1. Add a middleware to store language in continuous local storage and retrieve the value in user service.
  2. Create docker-compose.yml to install docker image of mailtrap and test send email locally

Final thoughts

Another use case of nestjs-i18n is to translate email according to the language of http request. We translate the contents by message keys and pass the arguments into Mjml template to replace variables by i18n values. Then, we can preview the emails on browser and send to inbox of email client.

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-mjml-poc
  2. Mjml: https://documentation.mjml.io/#inside-node-js
  3. nestjs-i18n: https://github.com/ToonvanStrijp/nestjs-i18n
  4. Mailtrap: https://mailtrap.io/

Add i18n support in nest app with nestjs-i18n

Reading Time: 7 minutes

 151 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