DEV Community

aabdullin
aabdullin

Posted on • Updated on

Making Node.js Backend API Localization without i18next

I think many developers sooner or later face the need to localize / internationalize their applications, whether it is localizing texts or simply collecting metrics about different user groups, in this article I will cover the topic of backend localization on Node.js.
In this case, I will cover the topic of sending messages about the failure of the request, at its various stages, starting from the usual messages in the request body, and the localization of error messages at the request body validation stage.

Server message example

I note right away that all examples will be using the Fastify web framework, but are also fully applicable to Express, Koa, and other web frameworks.

And so, first of all, let's get acquainted with the visualization of the request life cycle, and how the implementation of the localization work will work:

Fastify request lifecycle

Pay attention to two main parts:

  • Business logic - localization of errors / texts that will be sent as a result of working with business logic, here you can also include simple errors from preHandlers, for example, authorization
  • Validation - the logic that is responsible for validating the request body req.body, (checking user authorization is not included here)

Below we will primarily consider working with the business logic layer, and at the end of the article there will be an example of validation localization.

Before reading this article, you should have an understanding of how error handling works in Fastify, Express, etc.

Language Detector strategy

And so, first of all, when we talk about localization, the question arises of a strategy for determining the user's language, in the case of a Backend application, we have two most used methods:

  • Accept-Language HTTP request header - which the browser automatically sends based on the browser's language settings (recommended method)
  • GEO IP - determining the location and, accordingly, the user's language based on his IP address

We will use the HTTP header Accept-Language, since in my practice this method gives the greatest accuracy in determining the user's language, and is also the easiest to implement.

Implementation

And so, first of all, we need to determine the language from the Accept-Language header, we need to install the Locale package:

# Install with yarn
yarn add locale

# Install with npm
npm install locale --save
Enter fullscreen mode Exit fullscreen mode

And now I propose to create a directory i18n in the project, which will contain a function helper for determining the language, and a little later we will add dictionaries with texts for localization there:

// i18n/index.ts
import locale from 'locale'

// Constant with the languages that we will support
export enum Language {
  DE = 'de',
  EN = 'en',
}
// fallback language
const defaultLang = Language.DE

const supportedLangs = new locale.Locales(Object.values(Language), defaultLang)
Enter fullscreen mode Exit fullscreen mode

Next, let's add the parseAcceptLanguage function, which will determine the language depending on the header:

// i18n/index.ts
export const parseAcceptLanguage = (acceptLanguage: string): Language => {
  const locales = new locale.Locales(acceptLanguage)
  const language = locales.best(supportedLangs).toString() as Language

  return language
}
Enter fullscreen mode Exit fullscreen mode

Now I propose to create the same dictionaries with texts for different languages.
As an example, let's create two dictionaries for the English language EN and for the German language DE.

// i18n/dictionaries/en.ts
export default {
  EMAIL_ALREADY_IN_USE: 'This Email is already in use!',
  USERNAME_ALREADY_IN_USE: 'This username is already in use!',
  LOGIN_ERROR: 'Wrong Username/Email/Password',
  FILE_LIMIT_MB: 'The file in the \'#fieldName#\' field exceeds the file size limit of #mb# MB',
}

// i18n/dictionaries/de.ts
export default {
  EMAIL_ALREADY_IN_USE: 'Diese E-Mail-Adresse wird schon verwendet!',
  USERNAME_ALREADY_IN_USE: 'Dieser Benutzername wird bereits benutzt!',
  LOGIN_ERROR: 'Falscher Benutzername/E-Mail/Passwort',
  FILE_LIMIT_MB: 'Die Datei im Feld \'#fieldName#\' überschreitet die Dateigrößenbeschränkung von #mb# MB',
}

// i18n/index.ts
import ru from './dictionaries/ru'
import en from './dictionaries/en'

export const dictionaries: Record<Language, typeof en> = {
  ru,
  en,
}
Enter fullscreen mode Exit fullscreen mode

You may have noticed that in some keys there are slots like #fieldName# or #mb#, this is done for a reason, but in order to be able to specify additional arguments at the time of throwing an error that will be entered there dynamically.

And so now let's write our own class for the thrown error, which we will later catch and transform into messages with localization.

// errors.ts
type Slots = Record<string, string | number> | null

export class HttpError extends Error {
  status: number
  slots: Slots

  constructor(status: number, message = '', slots: Slots = null) {
    super()

    this.status = status
    this.message = message
    this.slots = slots
  }
}
Enter fullscreen mode Exit fullscreen mode

And now let's put everything together, and get a lightweight and flexible localization mechanism.
And we will collect everything in place at the time of error handling, since Fastify is already able to handle exceptions out of the box, and using the setErrorHandler we can customize it, which will allow us to check if the error is an instance of the HttpError class, then try to check for slots, and send a message to the user.

// server.ts
app.setErrorHandler((error, req, res) => {
  if (typeof req.lang !== 'string') {
    req.lang = parseAcceptLanguage(req.headers['accept-language']!)
  }

  const dictionary = dictionaries[req.lang]
  let message = dictionary[error.message] ?? error.message

  if (error instanceof HttpError) {
    if (error.slots !== null) {
      const slots = Object.keys(error.slots)

      for (const slot of slots) {
        message.replace(`#${slot}#`, slots[slot])
      }
    }

    return res.status(error.status).send({
      status: error.status,
      message,
    })
  }

  res.status(500).send({
    status: 500,
    message,
  })
})
Enter fullscreen mode Exit fullscreen mode

Also don't forget to add the lang field to the FastifyRequest object in your type declaration files.

// types.ts
declare module 'fastify' {
  interface FastifyRequest {
    lang?: Language
  }
}
Enter fullscreen mode Exit fullscreen mode

And that's it!
Now in the business logic layer, we can throw exceptions that will be localized for each specific user:

// someHandler.ts
const emailInDb = await UserModel.exists({ email })
if (emailInDb) {
  throw new HttpError(400, 'EMAIL_ALREADY_IN_USE')
}

// or an example using slots
if (file.size > MBtoBytes(10)) {
  throw new HttpError(400, 'FILE_LIMIT_MB', {
    mb: 10,
    fieldName: 'photo',
  })
}
Enter fullscreen mode Exit fullscreen mode

Validation messages localization

And now let's move on to validation, for example, I will take a fairly popular library Yup.

I know that Yup has built-in mechanisms for localization, but the purpose of this article is to look at validation not as ready-made errors that the library throws out to us, but as a pair of error key -> value + metadata that all libraries adhere to in one way or another, and having understood this, it will not be a problem to make localization based on any library

Here is an example of a schema that is responsible for validation:

// schemas.ts
import { object, string } from 'yup'

const createSchema = object().shape({
  name: string().required(),
  email: string().min(20).required(),
  sessionId: string().required(),
})
Enter fullscreen mode Exit fullscreen mode

And now let's write a function that will turn the scheme into preHandler automatically:

// errors.ts
export const validateYup = schema => async req => {
  try {
    await schema.validate(req.body, {
      abortEarly: false,
    })
  } catch (e) {
    req.lang = parseAcceptLanguage(req.headers['accept-language']!)

    throw new ValidationError(422, { data: translateYupError(e, req.lang) })
  }
}
Enter fullscreen mode Exit fullscreen mode

And now, when defining a route, we will simply write preHandler: validateYup(createSchema).

Next, we will add types of Yup errors to our dictionary, be it required or min, but I also recommend adding a YUP_ prefix for them so that in which case they can be easily separated from other similar errors related to validation:

// i18n/dictionaries/en.ts
export default {
  YUP_MIN: ({ params }) => `Min ${params.min}`,
  YUP_REQUIRED: 'Required text'
}
Enter fullscreen mode Exit fullscreen mode

Don't be surprised that YUP_MIN is a function, in this case it's the easiest way to pass error metadata, in other cases I recommend sticking to the slot syntax described above.

But I think you noticed that the main magic lies in the translateYupError function, that's actually it:

// errors.ts
const translateYupError = (errors, lang) => errors.inner
  .map(error => {
    const key = `YUP_${error.type.toUpperCase()}`
    const message = dictionary[lang][key] ?? error.type // or fallback to error.message

    return {
      message: typeof message === 'function'
        ? message(error)
        : message,
      field: error.path,
    }
  })
Enter fullscreen mode Exit fullscreen mode

And now in the response with a validation error, we will have all the fields localized, since we will get into setErrorHandler where the ValidationError error will be processed.

And the user will see the error in his native language :)

Top comments (0)