Skip to content

A fully type-safe and lightweight internationalization library for all your TypeScript and JavaScript projects.

License

Notifications You must be signed in to change notification settings

harunzafer/typesafe-i18n

 
 

Repository files navigation

🌍 typesafe-i18n

An opinionated, fully type-safe, lightweight localization library for TypeScript and JavaScript projects with no external dependencies.

npm version types included bundle size bump version & publish to npm Generic badge

Advantages

🐤 lightweight (~1kb)
👌 easy to use syntax
🏃 fast and efficient
🦺 prevents you from making mistakes (also in plain JavaScript projects)
👷 creates boilerplate code for you
💬 supports plural rules
📅 allows formatting of values e.g. locale-dependent date or number formats
↔️ supports switch-case statements e.g. for gender-specific output
⬇️ option for asynchronous loading of locales
📚 supports multiple namespaces
⏱️ supports SSR (Server-Side Rendering)
🤝 can be used for frontend, backend and API projects
🔍 locale-detection for browser and server environments
🔄 import and export translations from/to files or services
⛔ no external dependencies

Table of Contents

  • Get started - how to add typesafe-i18n to your project
  • Usage - how to implement different use-cases
  • Typesafety - how to get the best typesafety features
  • Syntax - how to write your translations
  • Dictionary - how to structure your translations
  • Namespaces - how to optimize loading of your translations
  • Formatters - how to format dates and numbers
  • Switch-Case - how to output different words depending on an argument
  • Locale-detection - how to detect an user's locale
  • Integrations - how to integrate other i18n services
  • Sizes - how much does typesafe-i18n add to your bundle size
  • Performance - how efficient is typesafe-i18n implemented
  • FAQs - how to get your questions answered

Get started

  1. Run the setup process and automatically detect the config needed

    npx typesafe-i18n --setup-auto

    or manually configure typesafe-i18n by answering a few questions

    npx typesafe-i18n --setup

    It didn't work? See here for possible troubleshooting.

  2. 👀 Take a look at the generated files and it's folder-structure

  3. 📖 Explore the docs

    typesafe-i18n offers a lot. Just press cmd + F to search on this page.

  4. ⭐ Star this project on GitHub

    Thanks! This helps the project to grow.


Having trouble setting up typesafe-i18n? Reach out to us via Github Discussions or on Discord.

manual installation

npm install typesafe-i18n

changelog

The changelog can be found here

migrations

Usage

The package can be used inside JavaScript and TypeScript applications. You will get a lot of benefits by running the generator since it will create a few wrappers to provide you with full typesafety.

You can use typesafe-i18n in a variety of project-setups:

Browser Support

The library should work in all modern browsers. It uses some functionality from the Intl namespace. You can see the list of supported browsers here. If you want to support older browsers that don't include these functions, you would need to include a polyfill like intl-pluralrules.

Other frameworks

All you need is inside the generated file i18n-utils.ts. You can use the functions in there to create a small wrapper for your application.

Feel free to open a new discussion if you need a guide for a specific framework.

Custom usage

If the provided wrappers don't fit your needs, you can use these raw functions to implement a custom i18n integration. The typesafe-i18n package exports a few different objects you can use to localize your applications:

  • i18nString (LLL): string interpolation for selected parts of an application
  • i18nObject (LL): for frontend-applications or projects that only load a single locale per user
  • i18n (L): for APIs or backend-applications that need to handle multiple locales

In order to get full typechecking support, you should use the exported functions in i18n-utils.ts created by the generator. It contains fully typed wrappers for the following core functionalities.

i18nString

The i18nString contains the core of the localization engine. To initialize it, you need to pass your desired locale and the formatters (optional) you want to use.
You will get an object back that can be used to transform your translation strings.

import { i18nString } from 'typesafe-i18n'

const locale = 'en'
const formatters = {
   uppercase: (value) => value.toUpperCase()
}

const LLL = i18nString(locale, formatters)

LLL('Hello {name|uppercase}!', { name: 'world' }) // => 'Hello WORLD!'

i18nObject

The i18nObject wraps your translations for a certain locale. To initialize it, you need to pass your desired locale, your translations-object and the formatters (optional) you want to use.
You will get an object back that can be used to access and apply your translations.

import { i18nObject } from 'typesafe-i18n'

const locale = 'en'
const translations = {
   HI: "Hello {name}!",
   RESET_PASSWORD: "reset password"
   /* ... */
}
const formatters = { /* ... */ }

const LL = i18nObject(locale, translations, formatters)

LL.HI({ name: 'world' }) // => 'Hello world!'
LL.RESET_PASSWORD() // => 'reset password'
nested-translations

You can group sections of your application by defining nested translations:

import { i18nObject } from 'typesafe-i18n'

const translations = {
   login: {
      greeting: 'Welcome to XYZ',
      labels: {
         'email': 'email-address:',
         'pw': 'password:'
      },
   },
   projects: {
      count: '{0} project{{s}}',
   }
}
const LL = i18nObject('en', translations)

LL.login.labels.email() // => 'email-address:'
LL.projects.count(3) // => '3 projects'

Nesting can be as deep as you want. The only restriction is that you can't use the '.' character for your translation keys.

i18n

Wrap all your locales with i18n. To initialize it, you need to pass a callback to get the translations-object for a given locale and a callback to initialize the formatters you want to use (optional).
You will get an object back that can be used to access all your locales and apply your translations.

import { i18n } from 'typesafe-i18n'

const localeTranslations = {
   en: { TODAY: "Today is {date|weekday}" },
   de: { TODAY: "Heute ist {date|weekday}" },
   it: { TODAY: "Oggi è {date|weekday}" },
}

const loadLocale = (locale) => localeTranslations[locale]

const initFormatters = (locale) => {
   const dateFormatter = new Intl.DateTimeFormat(locale, { weekday: 'long' })

   return {
      weekday: (value) => dateFormatter.format(value)
   }
}

const L = i18n(loadLocale, initFormatters)

const now = new Date()

L.en.TODAY({ date: now }) // => 'Today is friday'
L.de.TODAY({ date: now }) // => 'Heute ist Freitag'
L.it.TODAY({ date: now }) // => 'Oggi è venerdì'

A good usecase for this object could be inside your API, when your locale is dynamic e.g. derived from a users session:

function doSomething(session) {

   /* ... */

   const locale = session.language
   return L[locale].SUCCESS_MESSAGE()
}

Typesafety

The typesafe-i18n package allows us to be 100% typesafe for our translation functions and even the translations for other locales itself. It generates TypeScript definitions based on your base locale. Here you can see some examples where the generated types can help you:

typesafe auto-completion for all your defined locales

typesafe locales completion

typesafe auto-completion for all available translations

typesafe translation key completion

you will get an error if you forget to pass arguments

typesafe number of arguments

you will get an error if you pass the wrong type arguments

typesafe arguments 1 typesafe arguments 2

you will get an error if you forgot to add a translation in a locale

typesafe keys in translations

you will get an error when a translation is missing an argument

typesafe arguments in translation

In order to get get full typesafety for your locales, you can start the generator during development. The generator listens for changes you make to your base locale file and creates the corresponding TypeScript types.

You will also benefit from full typesafe JavaScript code via JSDoc-annotations.

Make sure you have installed node version > 12.x and are using a typescript version >= 3.5.1.

The generator will create a different output depending on your TypeScript version. Older versions don't support all the features typesafe-i18n need to provide you with the best types. Make sure to use a TypeScript version > 4.1.x to benefit from all the typechecking features.

You can choose between three variants to run the generator:

node-process

Start the generator node process in your terminal:

> npx typesafe-i18n

or define a script in your package.json file:

{
   "scripts": {
      "typesafe-i18n": "typesafe-i18n"
   }
}

You could use a npm-package like npm-run-all in order to start the generator and you development-server in parallel.

To start the generator and watch for changes in your translation you can run:

> npm run typesafe-i18n

Passing options to the generator is possible by creating a .typesafe-i18n.json file in the root of your workspace.

running the generator in CI/CD

When running tests or scripts you can disable the watcher by passing the argument --no-watch to the generator node-process:

> npx typesafe-i18n --no-watch

This will only generate types once and not listen to changes in your locale files. The process will throw an TypesafeI18nParseError if a wrong syntax is detected in your base locale file.

rollup-plugin

If you are using rollup as your bundler, you can add the typesafeI18nPlugin to your rollup.config.js.

import { typesafeI18nPlugin } from 'typesafe-i18n/rollup/rollup-plugin-typesafe-i18n'

export default {
   input: ...,
   output: ...,
   plugins: {
		...
      typescript(),

      // looks for changes in your base locale file in development mode
      // optimizes code in production mode
      typesafeI18nPlugin({ /* optimization-options go here */ })
   }
}

You can pass options to the generator by creating a .typesafe-i18n.json file in the root of your workspace.

The rollup plugin can be used to optimize the translations for your production bundle.

Currently implemented optimizations:

  • get rid of the arguments type information inside your base-translation:
    These types inside your base translations e.g. Hello {name:string}! are only used from the generator to create types for your translations. The rollup plugin removes these types from the translations in order to reduce bundle size by a few bytes. The example above will be optimized to Hello {name}! inside your production bundle.
  • include only certain locales:
    If you want to create an own bundle per locale. When running rollup to create your production-bundle, you can specify the 'locales' optimization-config to include only certain locales. The rollup plugin will remove all other locales from the production bundle.
    export default {
       plugins: {
          typesafeI18nPlugin({ locales: ['de'] })
       }
    }

More optimizations will follow.

webpack-plugin

If you are already using webpack as your bundler, you can add the TypesafeI18nPlugin to your webpack.config.js.

const TypesafeI18nPlugin = require('typesafe-i18n/webpack/webpack-plugin-typesafe-i18n').default

module.exports = {
   entry: ...,
   module: ...,
   output: ...,
   plugins: [
      ...
      // looks for changes in your base locale file in development mode
      new TypesafeI18nPlugin({ /* options go here */ })
   ],
}

You can pass options to the generator by creating a .typesafe-i18n.json file in the root of your workspace.

folder structure

This project requires you to use an opinionated folder structure for your locales. All your localization files are located inside the src/i18n folder.

When running the generator for the first time, a few files will be created:

src/
   i18n/
      en/
         index.ts
      custom-types.ts
      formatters.ts
      i18n-types.ts
      i18n-util.async.ts
      i18n-util.sync.ts
      i18n-util.ts

Some files are auto-generated on every change of your base locale file; please don't make manual changes to them, since they will be overwritten.

  • en/index.ts
    If 'en' is your base locale, the file src/i18n/en/index.ts will contain your translations. Whenever you make changes to this file, the generator will create updated type definitions.

  • custom-types.ts
    To defining types that are unknown to typesafe-i18n.

  • formatters.ts
    In this file, you can configure the formatters to use inside your translations.

  • i18n-types.ts
    Type definitions are generated in this file. You don't have to understand them. They are just here to help TypeScript understand, how you need to call the translation functions.

  • i18n-util.async.ts
    This file contains the logic to load individual locales asynchronously.

  • i18n-util.ts
    This file contains the logic to load your locales in a synchronous way.

  • i18n-util.ts
    This file contains wrappers with type-information around the base i18n functions.

loading locales

You can choose how you want to load locales depending on your application use-case

asynchronous loading of locales

If your app get's loaded via a network request (probably most websites and -applications) you should use the loadLocaleAsync function provided by src/i18n/i18n-util.async.ts. It only loads the locale that is currently needed to render the page. No unnecessary data from other locales is transferred to your users. The function returns a Promise that loads the dictionary and initializes your formatters.

import { loadLocaleAsync } from './i18n/i18n-util.async'
import { i18nObject } from './i18n/i18n-util'

let LL

const switchLocale = async (locale) => {
   await loadLocaleAsync(locale)
   LL = i18nObject(locale)
}

synchronous loading of locales

If you are using typesafe-i18n in a server or API context, you can load all locales when the app starts by using the loadAllLocales function provided by src/i18n/i18n-util.sync.ts. The function loads all dictionaries and initializes the formatters for each locale.

import { loadAllLocales } from './i18n/i18n-util.sync'
import { i18n } from './i18n/i18n-util'

const locale = 'en'

loadAllLocales(locale)

const L = i18n(locale)

locales

Locales must follow a specific file pattern. For each locale, you have to create a folder with the name of the locale inside your src/i18n folder e.g. 'en', 'en-us', 'en-GB'. The name of the folder is also the name of the locale you use inside your code. Each locales folder needs to have an index.ts file with a default export. The file should export an object with key-values pairs or arrays and should look something like:

import type { Translation } from '../i18n-types';

const de: Translation = {

   /* your translations go here */

}

export default de

make sure to give it the type of Translation to get compile-errors, when some translations are missing

custom types

If you want to pass arguments with your own types to the translation function, you need to tell typesafe-i18n how these types look like. In order to do this, you need to create an export with the exact name of that type inside this file.

If you have a translation with e.g. the type Sum,

const translations: BaseTranslation = {
   RESULT: 'The result is: {0:Sum|calculate}'
}

you need to export Sum as a type in your custom-types.ts file

export type Sum = {
   n1: number
   n2: number
   n2: number
}

Options

You can set options for the generator inside a .typesafe-i18n.json-file in your project's root folder e.g.

{
   "$schema": "https://unpkg.com/[email protected]/schema/typesafe-i18n.json",

   "baseLocale": "de",
   "adapter": "svelte"
}

if you add the $schema inside your config, you will get hints and auto-completion suggestions inside your IDE. Just make sure the link points to the version you are currently using.

The available options are:

key type default value
adapter 'angular' | 'node' | 'react' | 'svelte' | 'vue' | undefined undefined
baseLocale string 'en'
outputFormat 'TypeScript' | 'JavaScript' 'TypeScript'
esmImports boolean false
generateOnlyTypes boolean false
runAfterGenerator string | undefined undefined
banner string '/* eslint-disable */'
outputPath string './src/i18n/'
typesFileName string 'i18n-types'
utilFileName string 'i18n-util'
formattersTemplateFileName string 'formatters'
typesTemplateFileName string 'custom-types'
adapterFileName string | undefined undefined
tempPath string './node_modules/typesafe-i18n/temp-output/'

adapter

If this config is set, code will be generated that wraps i18n functions into useful helpers for that environment e.g. a svelte-store.

baseLocale

Defines which locale to use for the types generation. You can find more information on how to structure your locales here.

outputFormat

The programming language you use inside your code. If 'TypeScript' is selected, the generator will output TypeScript code and types. If you choose 'JavaScript' the generator will output typesafe JavaScript code annotated with JSDoc-comments.

esmImports

If true generated files will import other files with the .js file extension. This makes it compatible with ESM packages that have specified "type": "module" in their package.json file.

generateOnlyTypes

If you don't want to use the auto-generated helpers and instead write your own wrappers, you can set this option to true.

runAfterGenerator

This hook allows you to e.g. run a code formatting/linting command after the generator completes. When a command is provided, a child_process gets spawned with that command e.g. npm run prettier --write "src/i18n"

banner

This text will be output on top of all auto-generated files. It is meant to add a custom disable-linter comment. Since every project can have different lint rules, we want to disable linting on those files.

outputPath

Folder in which the files should be generated and where your locale files are located.

typesFileName

Name for the file where the types for your locales are generated.

utilFileName

Name for the file where the typesafe i18n-functions are generated.

formattersTemplateFileName

Name for the file where you can configure your formatters.

typesTemplateFileName

Name for the file where you can configure your custom-types.

adapterFileName

Name for the file when generating output for an adapter. The default filename is i18n-[adapter].

tempPath

Folder where the generator can store temporary files. These files are generated when your base locale is analyzed and the types are generated. The folder will be cleared, after the types were generated. So make sure you use an empty folder, if you change this option.

JSDoc

If you want to use typesafe-i18n inside your JavaScript code, you can get also full typesafety by running the generator with the outputFormat option set to 'JavaScript'. The generator then provides wrappers for the core functions together with JSDoc annotations.

An IDE like VS Code will show you code-completions and errors when you have opened a file in the editor.

In order to get typesafety for your locales files, you need to annotate it like this:

// @ts-check

/**
 * @typedef { import('../i18n-types').Translation } Translation
 */

/** @type { Translation } */
module.exports = {

   /* your translations go here */

}

Syntax

For more information about the LLL object, read the usage section.

passing arguments:

Syntax: {index}

const APPLES = '{0} apples'

LLL(APPLES, 12) // => '12 apples'

passing multiple arguments:

const FRUITS = '{0} apples and {1} bananas'

LLL(FRUITS, 3, 7) // => '3 apples and 7 bananas'

passing keyed arguments:

Syntax: {key}

const FRUITS = '{nrOfApples} apples and {nrOfBananas} bananas'

LLL(FRUITS, { nrOfApples: 3, nrOfBananas: 7 }) // => '3 apples and 7 bananas'

plural:

Syntax: {{singular|plural}}

const APPLES = '{nrOfApples} {{apple|apples}}'

LLL(APPLES, { nrOfApples: 1 }) // => '1 apple'
LLL(APPLES, { nrOfApples: 2 }) // => '2 apples'

plural (shorthand):

Syntax: {{plural}}

const APPLES = '{nrOfApples} apple{{s}}'

LLL(APPLES, { nrOfApples: 0 }) // => '0 apples'
LLL(APPLES, { nrOfApples: 1 }) // => '1 apple'
LLL(APPLES, { nrOfApples: 5 }) // => '5 apples'

plural (shorthand for only singular version):

Syntax: {{singular|}}

const MEMBERS = '{0} weitere{{s|}} Mitglied{{er}}'

LLL(MEMBERS, 0) // => '0 weitere Mitglieder'
LLL(MEMBERS, 1) // => '1 weiteres Mitglied'
LLL(MEMBERS, 9) // => '9 weitere Mitglieder'

plural (zero, one, other):

Syntax: {{zero|one|other}}

const LIST_ITEMS = 'The list includes {{ no items | an item | ?? items }}'

LLL(LIST_ITEMS, 0) // => 'The list includes no items'
LLL(LIST_ITEMS, 1) // => 'The list includes an item'
LLL(LIST_ITEMS, 12) // => 'The list includes 12 items'

plural (inject passed argument):

Syntax: {{singular|?? plural}}

const BANANAS = '{{ a banana | ?? bananas }}'

LLL(BANANAS, 1) // => 'a banana'
LLL(BANANAS, 3) // => '3 bananas'

plural (full syntax):

Under the hood, typesafe-i18n uses the Intl.PluralRules for detecting the plural form.
The only small modification made is, that the values 0 and '0' are always mapped to 'zero' instead of 'other'.

Syntax: {{zero|one|two|few|many|other}}

// locale set to 'ar-EG'

const PLURAL = 'I have {{zero|one|two|a few|many|a lot}} apple{{s}}'

LLL(PLURAL, 0) // => 'I have zero apples'
LLL(PLURAL, 1) // => 'I have one apple'
LLL(PLURAL, 2) // => 'I have two apples'
LLL(PLURAL, 6) // => 'I have a few apples'
LLL(PLURAL, 18) // => 'I have many apples'

plural (key reference):

Sometimes you may need to specify the key you want to use for defining the plural output.

Syntax: {{key:[plural-syntax]}}

const BANANAS = 'banana{{nrOfBananas:s}}'

LLL(BANANAS, { nrOfBananas: 1 }) // => 'banana'
LLL(BANANAS, { nrOfBananas: 3 }) // => 'bananas'

format passed in arguments:

Read the formatters section to learn how you can configure formatters.

const now = Date.now()

LLL('Today is {date|weekday}', { date: now }) // => 'Today is Friday'
LLL('Heute ist {date|weekday}', { date: now }) // => 'Heute ist Freitag'

Allows also to format values by multiple formatters in row. The formatters will be called from left to right.

const now = Date.now()

LLL('Today is {date|weekday}', { date: now }) // => 'Today is Friday'
LLL('Today is {date|weekday|uppercase}', { date: now }) // => 'Today is FRIDAY'
LLL('Today is {date|weekday|uppercase|shorten}', { date: now }) // => 'Today is FRI'

typesafe nr of arguments:

For information about the LL object, read the usage section.

const translation = {
   HI: 'Hello {0}'
}

LL.HI() // => ERROR: Expected 1 arguments, but got 0.
LL.HI('John', 'Jane') // => ERROR: Expected 1 arguments, but got 2.
LL.HI('John') // => 'Hi John'

typesafe arguments:

Syntax: {key:type}

const translation = {
   HI: 'Hello {name:string}'
}

LL.HI('John') // => ERROR: Argument of type 'string' is not assignable to parameter of type '{ name: string; }'.
LL.HI({ name: 'John' }) // => 'Hi John'

everything together:

const MESSAGE = 'Hi {name:string|uppercase}, I want to buy {nrOfApples:number} apple{{s}}'

LLL(MESSAGE, { name: 'John', nrOfApples: 42 }) // => 'Hi JOHN, I would like to buy 42 apples'

text only:

Of course typesafe-i18n can handle that as well.

LLL('Welcome to my site') // => 'Welcome to my site'

Or if you are using the i18nObject (LL):

<script>
   const translation = {
      LOGIN: 'login'
   }
<script>

<div>
   {LL.LOGIN()} <!-- => 'login' -->
</div>

arrays:

typesafe-i18n also supports Arrays as a dictionary.
Here are some example how you can iterate over them:

<script>
   const translation = [
      'first item',
      'second item'
   ]

   // calling translation
   console.log(LL[0]()) // => 'first item'

   // get length of array
   const length = Array.from(LL).length

   // for loop
   for (let i = 0; i < Array.from(LL).length; i++) {
      console.log(LL[i]())
   }

   // forEach loop
   Array.from(LL).forEach((entry) => {
      console.log(entry())
   })

   // for of loop
   for (const entry of LL) {
      console.log(entry())
   }

   // for in loop
   for (const entry in LL7) {
      console.log(LL[entry]())
   }
<script>

Dictionary

To define your base translation you need to create a index.ts file inside src/i18n/{baseLocale}, where baseLocale can be defined inside the options. This file must have an default export that should have the type of BaseTranslation | BaseTranslation[]. Something like this:

import type { BaseTranslation } from '../i18n-types'

const en: BaseTranslation = { }

export default en

You are really flexible how you want to define your translations. You could define them:

  • as key-value pairs:

    const en: BaseTranslation = {
       HI: 'Hello',
       LOGIN: 'click here to login'
       LOGOUT: 'logout'
    }

    keys can also be lowercase

  • as nested key-value pairs (recommended):

    const en: BaseTranslation = {
       hi: 'Hello',
       auth: {
          login: 'click here to login'
          logout: 'logout'
       }
    }

    can be nested as deep as you want

  • as an array:

    const en: BaseTranslation = [
       'Hello',
       'click here to login'
       'logout'
    ]
  • as a nested array:

    const en: BaseTranslation = [
       'Hello',
       [
          'click here to login'
          'logout'
       ]
    ]

    can be nested as deep as you want

  • mixed:

    const en: BaseTranslation = {
       HI: 'Hello',
       auth: [
          {
             login: 'click here to login'
          },
          'logout'
       ]
    }

You are really flexible how you define your translations. You can define the translations how it fits best to your application and i18n workflow. It is recommended to use nested key-value pairs since it offers flexibility and is easy to read, but if your translations come from an external service like a CMS, it is possible that you also have to use the array syntax to define your translations.

Namespaces

Namespaces allows you to split your translations in multiple files and only load them on demand. e.g. you could store all translations that are displayed on the settings-page in it's own namespace and only load them when a user visits the settings-page.

To create a namespace, you have to create a folder within your bas translations with the name of your namespace that contains a index.ts file.

src/
   i18n/
      en/
         settings/index.ts
         index.ts

In this example the base locale is en and we want to have a namespace called settings. The index.ts file has to export a Dictionary. e.g.

import type { BaseTranslation } from '../../i18n-types'

const en_settings: BaseTranslation = { }

export default en_settings

Once you have created that file, the generator will automatically create boilerplate namespace files for all your other locales and assign the correct type to them.

By default translations inside namespaces are not loaded. You have to manually load them via loadNamespaceAsync.

const displaySettingsPage = async (locale) => {
   await loadNamespaceAsync(locale, 'settings')
   setLocale(locale)

   // goto settings page
}

make sure to call setLocale after you load new namespaces !

Formatters

You can specify your own formatters, that take an argument as an input and returns another value.

const formatters = {
   roiCalculator: (value) => (value * 4.2) - 7
}

LLL('Invest ${0} and get ${0|roiCalculator} in return', 100)
// => 'Invest $100 and get $413 in return'

chaining formatters

If you need to apply multiple formatters to the same argument, you can chain them by using the pipe | operator:

const formatters = {
   sqrt: (value) => Math.sqrt(value),
   round: (value) => Math.round(value),
}

LLL('Result: {0|sqrt|round}', 5)
// => 'Result: 2'

The formatters get applied from left to right. So in this example 5 will be the input for the sqrt formatter, that will return 2.23606797749979. This value then gets passed to the round formatter that will output 2.

You can also use a few builtin formatters:

date

A wrapper for Intl.DateTimeFormat.

import { date } from 'typesafe-i18n/formatters'

const formatters = {
   weekday: date({ weekday: 'long' })
}

LLL('Today is {0|weekday}', new Date()) // => 'Today is friday'

See here if you want to use this formatter in a Node.JS environment.

time

Same as the date-formatter

import { time } from 'typesafe-i18n/formatters'

const formatters = {
   timeShort: time('en', { timeStyle: 'short' })
}

LLL('Next meeting: {0|timeShort}', meetingTime) // => 'Next meeting: 8:00 AM'

number

A wrapper for Intl.NumberFormat

import { number } from 'typesafe-i18n/formatters'

const formatters = {
   currency: number('en', { style: 'currency', currency: 'EUR' })
}

LLL('your balance is {0|currency}', 12345) // => 'your balance is €12,345.00'

See here if you want to use this formatter in a Node.JS environment.

replace

A wrapper for String.prototype.replace

import { replace } from 'typesafe-i18n/formatters'

const formatters = {
   noSpaces: replace(/\s/, '-')
}

LLL('The link is: https://www.xyz.com/{0|noSpaces}', 'super cool product')
// => 'The link is: https://www.xyz.com/super-cool-product'

identity

Returns the variable without modifications

import { identity } from 'typesafe-i18n/formatters'

const formatters = {
   myFormatter: identity // (value) => value
}

LLL('Hello {name|myFormatter}', 'John')
// => 'Hello John'

ignore

Ignores the variable and returns an empty string.

import { ignore } from 'typesafe-i18n/formatters'

const formatters = {
   myFormatter: ignore // () => ''
}

LLL('Hello {name|myFormatter}', 'John')
// => 'Hello '

uppercase

A wrapper for String.prototype.toUpperCase

import { uppercase } from 'typesafe-i18n/formatters'

const formatters = {
   upper: uppercase
}

LLL('I said: {0|upper}', 'hello') // => 'I said: HELLO'

lowercase

A wrapper for String.prototype.toLowerCase

import { lowercase } from 'typesafe-i18n/formatters'

const formatters = {
   lower: lowercase
}

LLL('He said: {0|lower}', 'SOMETHING') // => 'He said: something'

Switch-Case

In some situations you may need to output a different sentence based on some argument. For such use-cases you can take advantage of the switch-case syntax.

Syntax: {key | {case1: value1, case2: value2, *: defaultValue}}

Here is an example how the switch-case functionality can be used to output a person related sentence. Depending on the gender of the subject, a different wording is used:

const translations: Translation {
   photoAdded: '{username:string} added a new photo to {gender|{male: his, female: her, *: their}} stream.'
}

LL.photoAdded({ username: 'John', gender: 'male' })
// => 'John added a new photo to his stream.'
LL.photoAdded({ username: 'Jane', gender: 'female' })
// => 'Jane added a new photo to her stream.'
LL.photoAdded({ username: 'Alex', gender: 'other' })
// => 'Alex added a new photo to their stream.'

Other use-cases could be e.g. yes-no options:

const translations: Translation {
   tax: 'Price: ${price:number}. {taxes|{yes: An additional tax will be collected. , no: No taxes apply.}}'
}

LL.tax({ price: '999', taxes: 'yes' })
// => 'Price: $999. An additional tax will be collected.'
LL.tax({ price: '99', taxes: 'no' })
// => 'Price: $99. No taxes apply.'

You can define as many cases as you want.
Each case must be split by a comma (,).
Each case must contain a key and a value, where key and value are split by a colon (:).
If you want to define a default-case you have to use an asterisk (*) as a key.

Locale-detection

To automatically detect a users locale, you can use the detectLocale function:

import { detectLocale } from 'typesafe-i18n/detectors'

const detectedLocale = detectLocale(fallbackLocale, availableLocales, detector)

The function expects following parameters:

  • fallbackLocale: string
    When no matching locale is found this value is used.
  • availableLocales: string[]
    A list of locales your application supports.
  • detector: () => string[] A function that returns a list of locales. You can also pass multiple detectors. If the first detector does not find any matching locale, the next detector will be called and so on.
    detectLocale(fallbackLocale, availableLocales, detector1, detector2, /* ... */ detector7)

Detectors

Detectors can be used either on the browser or the server side. You can use the built-in detectors or write your own detector functions:

import { detectLocale } from 'typesafe-i18n/detectors'

const fallbackLocale = 'de'
const availableLocales = ['de', 'en' 'it', 'fr']

const customDetector = () => {
   const locale = detectRandomLocale() // your custom locale detector

   return [locale]
}

const detectedLocale = detectLocale(fallbackLocale, availableLocales, customDetector)

if you need to access any request-specific variable, you would need to "initialize" the function first, since the detector is called without any arguments. The "initialization" function is needed to bind some values that can be used when executing the function later. The return type of that function is the detector, you need to pass to the detectLocale function.

const initIpDetector = (req: Request) => {
   return () => {
      const locale = detectLocaleFromIpAddress(req) // your custom locale detector

      return [locale]
   }
}

app.use((req: Request, res: Response) => {
   const ipDetector = initIpDetector(req)
   const detectedLocale = detectLocale(fallbackLocale, availableLocales, ipDetector)

   res.json({
      locale: detectedLocale,
   })
})

typesafe-i18n offers a few built-in detectors you can use:

Server

This detectors are expected to run on a server-environment e.g. an express server or serverless function. These detectors all expect an express-compatible req object.

accept-language header

Reads and parses the 'accept-language' header.
e.g. 'accept-language: fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5'
The function needs to be initialized first and expects you to pass the Request object.

import { detectLocale, initAcceptLanguageHeaderDetector } from 'typesafe-i18n/detectors'

app.use((req: Request, res: Response) => {
   const acceptLanguageHeaderDetector = initAcceptLanguageHeaderDetector(req)
   // or: const acceptLanguageHeaderDetector = initAcceptLanguageHeaderDetector(req, 'Accept-Language')
   const detectedLocale = detectLocale(fallbackLocale, availableLocales, acceptLanguageHeaderDetector)

   res.json({
      locale: detectedLocale,
   })
})

The default header is 'accept-language', but you can change it by passing a string as a second argument to the initAcceptLanguageHeaderDetector function.

cookies

Reads and parses the Request-cookies.
The function needs to be initialized first and expects you to pass the Request object.

import { detectLocale, initRequestCookiesDetector } from 'typesafe-i18n/detectors'

app.use((req: Request, res: Response) => {
   const requestCookiesDetector = initRequestCookiesDetector(req)
   // or: const requestCookies = initRequestCookiesDetector(req, 'user-lang')
   const detectedLocale = detectLocale(fallbackLocale, availableLocales, requestCookies)

   res.json({
      locale: detectedLocale,
   })
})

The default cookie name is 'lang', but you can change it by passing a string as a second argument to the initRequestCookiesDetector function.

parameters

Extracts the locale from the request's path.
e.g. /:lang/products
The function needs to be initialized first and expects you to pass the Request object.

import { detectLocale, initRequestCookiesDetector } from 'typesafe-i18n/detectors'

app.get('/:lang/products', (req: Request, res: Response) => {
   const requestParametersDetector = initRequestParametersDetector(req)
   // or: const requestParametersDetector = initRequestParametersDetector(req, 'user-lang')
   const detectedLocale = detectLocale(fallbackLocale, availableLocales, requestParametersDetector)

   res.json({
      locale: detectedLocale,
   })
})

The default parameter name is 'lang', but you can change it by passing a string as a second argument to the initRequestParametersDetector function.

Browser

This detectors are expected to run in a browser-environment e.g. on the website an user visits.

navigator

Detects the browser locales by accessing navigator.languages.

import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors'

const detectedLocale = detectLocale(fallbackLocale, availableLocales, navigatorDetector)

html lang attribute

Detects the locale on a website by reading the HTML lang attribute.

<html lang="en">
   <!-- your website's content-->
</html>
usage:
import { detectLocale, htmlLangAttributeDetector } from 'typesafe-i18n/detectors'

const detectedLocale = detectLocale(fallbackLocale, availableLocales, htmlLangAttributeDetector)

query-string

Detects the locale from a websites URL.
e.g. https://www.example.com/product-1/details.html?lang=de

usage:
import { detectLocale, queryStringDetector } from 'typesafe-i18n/detectors'

const detectedLocale = detectLocale(fallbackLocale, availableLocales, queryStringDetector)

The default parameter is 'lang', but you can change it by using the initQueryStringDetector function:

import { detectLocale, initQueryStringDetector } from 'typesafe-i18n/detectors'

const queryStringDetector = initQueryStringDetector('user-lang')
const detectedLocale = detectLocale(fallbackLocale, availableLocales, queryStringDetector)

localStorage

Looks for an entry inside the localStorage.

usage:
import { detectLocale, localStorageDetector } from 'typesafe-i18n/detectors'

const detectedLocale = detectLocale(fallbackLocale, availableLocales, localStorageDetector)

The default item-key is 'lang', but you can change it by using the initLocalStorageDetector function:

import { detectLocale, initLocalStorageDetector } from 'typesafe-i18n/detectors'

const localStorageDetector = initLocalStorageDetector('user-lang')
const detectedLocale = detectLocale(fallbackLocale, availableLocales, localStorageDetector)

sessionStorage

Looks for an entry inside the sessionStorage.

usage:
import { detectLocale, sessionStorageDetector } from 'typesafe-i18n/detectors'

const detectedLocale = detectLocale(fallbackLocale, availableLocales, sessionStorageDetector)

The default item-key is 'lang', but you can change it by using the initSessionStorageDetector function:

import { detectLocale, initSessionStorageDetector } from 'typesafe-i18n/detectors'

const sessionStorageDetector = initSessionStorageDetector('user-lang')
const detectedLocale = detectLocale(fallbackLocale, availableLocales, sessionStorageDetector)

cookies

Detects the locale by parsing the website's cookies.

Note: this detector can only read cookies that are not marked as secure. If your locale-cookie is set with a secure-context, the detector will not work.

usage:
import { detectLocale, documentCookieDetector } from 'typesafe-i18n/detectors'

const detectedLocale = detectLocale(fallbackLocale, availableLocales, documentCookieDetector)

The default cookie name is 'lang', but you can change it by using the initSessionStorageDetector function:

import { detectLocale, initDocumentCookieDetector } from 'typesafe-i18n/detectors'

const documentCookieDetector = initDocumentCookieDetector('user-lang')
const detectedLocale = detectLocale(fallbackLocale, availableLocales, documentCookieDetector)

Integration with other services

typesafe-i18n comes with an API that allows other services to read and update translations.

Services that work with typesafe-i18n:

  • inlang: An open source translation management dashboard with machine translations and automatic sync. Inlang allows non-technical team members, or external translators to adjust translations without touching the source code.

But you can also connect other services by using the importer and exporter functionality:

Importer

In order to import language files that come from an API, spreadsheet or JSON-files, typesafe-i18n provides an importer functionality. You have to write your own logic to get the data, then map it to a dictionary-representation and then call the storeTranslationToDisk function. Here is an example how this could look like:

import { storeTranslationToDisk } from 'typesafe-i18n/importer'

const getDataFromAPI = async (locale: string) => {
   // this is just an example that returns static content
   // in a real-world application, you would load the content from an API or from disk
   return {
      HI: 'Hello {name:string}',
      login: {
         validation: {
            username: 'Username "{username}" to short'
         }
      }
   }
}

const updateTranslations = async (locale: string) => {
   const translations = await getDataFromAPI(locale)

   const result = await storeTranslationToDisk({ locale, translations })
   // result = 'en'
}

updateTranslations('en')

you need to run this script during development or as a CI-process and not at runtime. Create an own file and run it with ts-node or something similar.

The storeTranslationToDisk will write the contents of your translations-object to the file src/i18n/{locale}/index.ts and will run the generator to update the types. The function will return a the locale as a string, if the import was successful.

If you want to store multiple translations in one step, you can call the storeTranslationsToDisk function.

Exporter

In order to export language files to a service or an API, typesafe-i18n provides an exporter functionality. You have to write your own logic to send the data. Here is an example how this could look like:

Please share your implementation in a PR. typesafe-i18n wants to provide built-in exporter-packages in the future.

import type { BaseTranslation } from 'typesafe-i18n'
import { readTranslationFromDisk } from 'typesafe-i18n/exporter'

const sendDataToAPI = async ({ locale, translations }: { locale: string; translations: BaseTranslation }) => {
   // custom implementation to store the data to a service
}

const sendTranslationsToService = async (locale: string) => {
   const translations = await readTranslationFromDisk(locale)

   await sendDataToAPI({ locale, translations })
}

sendTranslationsToService('en')

you need to run this script during development or as a CI-process and not at runtime. Create an own file and run it with ts-node or something similar.

The readTranslationFromDisk will load the contents of your translations-object from the file src/i18n/{locale}/index.ts if the file exists.

If you want to read multiple translations in one step, you can call the readTranslationsFromDisk function.

Sizes

The footprint of the typesafe-i18n package is smaller compared to other existing i18n packages. Most of the magic happens in development mode, where the generator creates TypeScript definitions for your translations. This means, you don't have to ship the whole package to your users. The only two parts, that are needed in production are:

  • string-parser: detects variables, formatters and plural-rules in your localized strings
  • translation function: injects arguments, formats them and finds the correct plural form for the given arguments

These parts are bundled into the core functions. The sizes of the core functionalities are:

Apart from that there can be a small overhead depending on which utilities and wrappers you use.

There also exists a useful wrapper for some frameworks:

Performance

The package was optimized for performance:

  • the amount of network traffic is kept small
    The translation functions are small. Only the locales that are used are loaded
  • no unnecessary workload
    Parsing your translation file for variables and formatters will only be performed when you access a translation for the first time. The result of that parsing process will be stored in an optimized object and kept in memory.
  • fast translations
    Passing variables to the translation function will be fast, because its treated like a simple string concatenation. For formatting values, a single function is called per formatter.

If you use typesafe-i18n you will get a smaller bundle compared to other i18n solutions. But that doesn't mean, we should stop there. There are planned some possible optimizations, to improve the bundle size even further:

  • get rid of the arguments type information inside your base-translation #13
  • inline translations for single-locale bundles #14
  • transform bundle so no runtime parsing is needed #68
  • rewrite keyed to index-based arguments #15

FAQs


Dou you still have some questions? Reach out to us via Github Discussions or on Discord.


Installing typesafe-i18n fails

Running the npx command with a npm version <7.0.0 will probably fail because it will not include peerDependencies.

You could try installing it locally via:

npm install typesafe-i18n

and then run the setup-command from within the node_modules folder via:

./node_modules/typesafe-i18n/cli/typesafe-i18n.mjs --setup-auto

here is the original issue with some additional information: #142


I added a new translation to my locale file, but TypeScript gives me the Error Property 'XYZ' does not exist on type 'TranslationFunctions'

Make sure to run the generator after you make changes to your base translation file. The generator will generate and update the types for you.


I don't use TypeScript, can I also use typesafe-i18n inside JavaScript applications?

Yes, you can. See the usage section for instructions. Even if you don't use TypeScript you can still improve from some typesafety features via JSDoc-annotations.


I added a new translation to my locale file, but the generator will not create new types

The generator will only look for changes in your base locale file. Make sure to always update your base locale file first, in order to get the correct auto-generated types. If you want to change your base locale file, make sure to give it the type of BaseTranslation. All other locales should have the type of Translation. E.g. if you set your base locale to italian, you would need to do it like this:

  • set your base locale to italian (it) in ´.typesafe-i18n.json`:

    {
       "baseLocale": "it"
    }
  • define the type of your base locale as BaseTranslation

    // file 'src/i18n/it/index.ts'
    import type { BaseTranslation } from '../i18n-types'
    
    const it: BaseTranslation = {
       WELCOME: "Benvenuto!"
    }
    
    export default it
  • define the type of your other locales as Translation

    // file 'src/i18n/en/index.ts'
    import type { Translation } from '../i18n-types'
    
    const en: Translation = {
       WELCOME: "Welcome!"
    }
    
    export default en

The generator keeps overriding my changes I make to the i18n-files

The generator creates some helpful wrappers for you. If you want to write your own wrappers, you can disable the generation of these files by setting the generateOnlyTypes option to true.


Is typesafe-i18n supported by i18n-ally?

Yes, you can configure i18n-ally like this. There is currently also an open PR that will add official support for typesafe-i18n.


How can I access a translation dynamically?

When you want to dynamically access a translation, you can use the usual JavaScript syntax to access a property via a variable (myObject[myVariable]).

  1. define your translations
// i18n/en.ts
import type { BaseTranslation } from '../i18n-types'

const en: BaseTranslation = {
   category: {
      simple: {
         title: 'Simple title',
         description: 'I am a description for the "simple" category',
      },
      advanced: {
         title: 'Advanced title',
         description: 'I am a description for the "advanced" category',
      }
   }
}

export default en
  1. use it in your components
<script lang="ts">
   // Component.svelte

   import LL from '$i18n/i18n-svelte'
   import type { Translation } from '$i18n/i18n-types'

   // ! do not type it as `string`
   // by restricting the type, you don't loose the typesafety features
   export let category: keyof Translation['category'] = 'simple'
</script>

<h2>{$LL.category[category].title()}

<p>
   {$LL.category[category].description()}
<p>

How do I render a component inside a Translation?

By default typesafe-i18n at this time does not provide such a functionality. But you could easily write a function like this:

import { LocalizedString } from 'typesafe-i18n'

// create a component that handles the translated message

interface WrapTranslationPropsType {
   message: LocalizedString,
   renderComponent: (messagePart: LocalizedString) => JSX.Element
}

export function WrapTranslation({ message, renderComponent }: WrapTranslationPropsType) {
   // define a split character, in this case '<>'
   let [prefix, infix, postfix] = message.split('<>') as LocalizedString[]

   // render infix only if the message doesn't have any split characters
   if (!infix && !postfix) {
      infix = prefix
      prefix = '' as LocalizedString
   }

   return <>
      {prefix}
      {renderComponent(infix)}
      {prefix}
   </>
}

// your translations would look something like this

const en = {
   'WELCOME': 'Hi {name:string}, click <>here<> to create your first project'
   'LOGOUT': 'Logout'
}

export default en


// create a wrapper for a component for easier usage

interface WrappedButtonPropsType {
   message: LocalizedString,
   onClick: () => void,
}

export function WrappedButton({ message, onClick }: WrappedButtonPropsType) {
   return <WrapTranslation
      message={message}
      renderComponent={(infix) => <button onClick={onClick}>{infix}</button>} />
}

// use it inside your application

export function App() {
   return <>
      <header>
         <WrappedButton message={LL.LOGOUT()} onClick={() => alert('do logout')}>
      </header>
      <main>
         <WrappedButton message={LL.WELCOME({ name: 'John' })} onClick={() => alert('clicked')}>
      </main>
   <>
}

This is an example written for a react application, but this concept can be used with any kind of framework.

Basically you will need to write a function that splits the translated message and renders a component between the parts. You can define your split characters yourself but you would always need to make sure you add them in any translation since typesafe-i18n doesn't provide any typesafety for these characters (yet).


I have two similar locales (only a few translations are different) but I don't want to duplicate my translations

Your locale translation files can be any kind of JavaScript object. So you can make object-transformations inside your translation file. The only restriction is: in the end it has to contain a default export with type Translation. You could do something like this:

  • create your BaseTranslation

    // file 'src/i18n/en/index.ts'
    import type { BaseTranslation } from '../i18n-types'
    
    const en: BaseTranslation = {
       WELCOME: "Welcome to XYZ",
       // ... some other translations
    
       COLOR: "colour"
    }
    
    export default en
  • create your other translation that overrides specific translations

    // file 'src/i18n/en-US/index.ts'
    import type { Translation } from '../i18n-types'
    import en from '../en' // import translations from 'en' locale
    
    const en_US: Translation = {
       ...en, // use destructuring to copy all translations from your 'en' locale
    
       COLOR: "color" // override specific translations
    }
    
    export default en_US

For certain locales I don't want to output a variable, but due to the strict typing I have to specify it in my translation

The generated types are really strict. It helps you from making unintentional mistakes. If you want to opt-out for certain translations, you can use the any keyword.

  • create your BaseTranslation with a translation containing a parameter

    // file 'src/i18n/en/index.ts'
    import type { BaseTranslation } from '../i18n-types'
    
    const en: BaseTranslation = {
       HELLO: "Hi {name}!",
    }
    
    export default en
  • create another locale without that parameter by disabling the strict type checking with as any

    // file 'src/i18n/de/index.ts'
    import type { Translation } from '../i18n-types'
    
    const de: Translation = {
       HELLO: "Hallo!" as any // we don't want to output the 'name' variable
    }
    
    export default de

WARNING! the usage of 'any' can introduce unintentional mistakes in future. It should only be used when really necessary and you know what you are doing.

A better approach would be to create a custom formatter e.g.

  • create your translation and add a formatter to your variable

    // file 'src/i18n/en/index.ts'
    import type { BaseTranslation } from '../i18n-types'
    
    const en: BaseTranslation = {
       HELLO: "Hi {name|nameFormatter}!",
    }
    
    export default en
    // file 'src/i18n/de/index.ts'
    import type { Translation } from '../i18n-types'
    
    const de: Translation = {
       HELLO: "Hallo {name|nameFormatter}!"
    }
    
    export default de
  • create the formatter based on the locale

    // file 'src/i18n/formatters.ts'
    import type { FormattersInitializer } from 'typesafe-i18n'
    import type { Locales, Formatters } from './i18n-types'
    import { identity, ignore } from 'typesafe-i18n/formatters'
    
    export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales)    => {
    
       const nameFormatter =
          locale === 'de'
             // return an empty string for locale 'de'
             ? ignore // same as: () => ''
             // return the unmodified parameter
             : identity // same as: (value) => value
    
       const formatters: Formatters = {
          nameFormatter: nameFormatter
       }
    
       return formatters
    }

Why does the translation function return a type of LocalizedString and not the type string itself?

With the help of LocalizedString you could enforce texts in your application to be translated. Lets take an Error message as example:

const showErrorMessage(message: string) => alert(message)

const createUser = (name: string, password: string) => {
   if (name.length === 0) {
      showErrorMessage(LL.user.create.nameNotProvided())
      return
   }

   if (isStrongPassword(password)) {
      showErrorMessage('Password is to weak')
      return
   }

   // ... create user in DB
}

In this example we can pass in any string, so it can also happen that some parts of your application are not translated. To improve your i18n experience a bit we can take advantage of the LocalizedString type:

import type { LocalizedString } from 'typesafe-i18n'

const showErrorMessage(message: LocalizedString) => alert(message)

const createUser = (name: string, password: string) => {
   if (name.length === 0) {
      showErrorMessage(LL.user.create.nameNotProvided())
      return
   }

   if (isStrongPassword(password)) {
      showErrorMessage('Password is to weak') // => ERROR: Argument of type 'string' is not assignable to parameter of type 'LocalizedString'.
      return
   }

   // ... create user in DB
}

With the type LocalizedString you can restrict your functions to only translated strings.


Tests are not running with Jest

Unfortunately there are some open issues in the Jest repository regarding modern package export formats so jest doesn't know where to load files from.

You need to manually tell jest where these files should be loaded from, by defining moduleNameMapper inside your jest.config.js:

// jest.config.js
module.exports = {
   moduleNameMapper: {
      "typesafe-i18n/adapters/(.*)": "typesafe-i18n/adapters/$1.cjs",
      "typesafe-i18n/detectors": "typesafe-i18n/detectors/index.cjs",
   }
};

here is the original issue with some additional information: #140


With Node.JS the Intl package does not work with locales other than 'en'

Node.JS, by default, does not come with the full intl support. To reduce the size of the node installment it will only include 'en' as locale. You would need to add it yourself. The easiest way is to install the intl package

> npm install intl

and then add following lines on top of your src/i18n/formatters.ts file:

const intl = require('intl')
intl.__disableRegExpRestore()
globalThis.Intl.DateTimeFormat = intl.DateTimeFormat

Then you should be able to use formatters from the Intl namespace with all locales.

Sponsors

Become a sponsor ❤️ if you want to support my open source contributions.

Backers

Kraftwurm Badlapje

Thanks for sponsoring my open source work!

About

A fully type-safe and lightweight internationalization library for all your TypeScript and JavaScript projects.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • TypeScript 100.0%