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.
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:
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
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)
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
}
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,
}
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
}
}
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,
})
})
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
}
}
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',
})
}
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(),
})
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) })
}
}
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'
}
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,
}
})
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)