An opinionated, fully type-safe, lightweight localization library for TypeScript and JavaScript projects with no external dependencies.
🐤 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
⬇️ 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
- 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
-
Run the setup process and automatically detect the config needed
npx typesafe-i18n --setup-auto
or manually configure
typesafe-i18n
by answering a few questionsnpx typesafe-i18n --setup
It didn't work? See here for possible troubleshooting.
-
👀 Take a look at the generated files and it's folder-structure
-
📖 Explore the docs
typesafe-i18n
offers a lot. Just presscmd + F
to search on this page. -
⭐ 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.
npm install typesafe-i18n
The changelog can be found here
- to version
4.x.x
: see therelease post
- to version
3.x.x
: see therelease post
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:
- Angular applications
- Browser (Vanilla JS) projects
- Node.js apis, backends, scripts, ...
- React / Next.js applications
- Svelte / SvelteKit / Sapper applications
- Vue.js applications
- other frameworks
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.
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.
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.
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!'
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'
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.
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()
}
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:
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:
- as a node-process (recommended)
- as a rollup-plugin
- as a webpack-plugin
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.
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.
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 toHello {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.
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.
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 filesrc/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 totypesafe-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.
You can choose how you want to load locales depending on your application use-case
- asynchronously: load each locale as a separate network request (recommended)
- synchronous: load all locales at once
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)
}
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 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
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
}
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/' |
If this config is set, code will be generated that wraps i18n functions into useful helpers for that environment e.g. a svelte
-store.
Defines which locale to use for the types generation. You can find more information on how to structure your locales here.
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.
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.
If you don't want to use the auto-generated helpers and instead write your own wrappers, you can set this option to true
.
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"
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.
Folder in which the files should be generated and where your locale files are located.
Name for the file where the types for your locales are generated.
Name for the file where the typesafe i18n-functions are generated.
Name for the file where you can configure your formatters.
Name for the file where you can configure your custom-types.
Name for the file when generating output for an adapter. The default filename is i18n-[adapter]
.
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.
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 */
}
For more information about the LLL
object, read the usage section.
Syntax:
{index}
const APPLES = '{0} apples'
LLL(APPLES, 12) // => '12 apples'
const FRUITS = '{0} apples and {1} bananas'
LLL(FRUITS, 3, 7) // => '3 apples and 7 bananas'
Syntax:
{key}
const FRUITS = '{nrOfApples} apples and {nrOfBananas} bananas'
LLL(FRUITS, { nrOfApples: 3, nrOfBananas: 7 }) // => '3 apples and 7 bananas'
Syntax:
{{singular|plural}}
const APPLES = '{nrOfApples} {{apple|apples}}'
LLL(APPLES, { nrOfApples: 1 }) // => '1 apple'
LLL(APPLES, { nrOfApples: 2 }) // => '2 apples'
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'
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'
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'
Syntax:
{{singular|?? plural}}
const BANANAS = '{{ a banana | ?? bananas }}'
LLL(BANANAS, 1) // => 'a banana'
LLL(BANANAS, 3) // => '3 bananas'
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'
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'
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'
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'
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'
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'
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>
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>
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 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 !
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'
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 thesqrt
formatter, that will return2.23606797749979
. This value then gets passed to theround
formatter that will output2
.
You can also use a few builtin formatters:
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.
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'
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.
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'
Returns the variable without modifications
import { identity } from 'typesafe-i18n/formatters'
const formatters = {
myFormatter: identity // (value) => value
}
LLL('Hello {name|myFormatter}', 'John')
// => 'Hello John'
Ignores the variable and returns an empty string.
import { ignore } from 'typesafe-i18n/formatters'
const formatters = {
myFormatter: ignore // () => ''
}
LLL('Hello {name|myFormatter}', 'John')
// => 'Hello '
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'
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'
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.
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 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:
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.
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.
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.
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.
This detectors are expected to run in a browser-environment e.g. on the website an user visits.
Detects the browser locales by accessing navigator.languages.
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors'
const detectedLocale = detectLocale(fallbackLocale, availableLocales, navigatorDetector)
Detects the locale on a website by reading the HTML lang
attribute.
<html lang="en">
<!-- your website's content-->
</html>
import { detectLocale, htmlLangAttributeDetector } from 'typesafe-i18n/detectors'
const detectedLocale = detectLocale(fallbackLocale, availableLocales, htmlLangAttributeDetector)
Detects the locale from a websites URL.
e.g. https://www.example.com/product-1/details.html?lang=de
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)
Looks for an entry inside the localStorage
.
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)
Looks for an entry inside the sessionStorage
.
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)
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.
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)
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:
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.
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.
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:
- i18nString: 949 bytes gzipped
- i18nObject: 1093 bytes gzipped
- i18n: 1121 bytes gzipped
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:
- typesafe-i18n angular-service: 1396 bytes gzipped
- typesafe-i18n react-context: 1597 bytes gzipped
- typesafe-i18n svelte-store: 1347 bytes gzipped
- typesafe-i18n vue-plugin: 1257 bytes gzipped
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
Dou you still have some questions? Reach out to us via Github Discussions or on Discord.
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.
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.
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 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
.
Yes, you can configure i18n-ally
like this. There is currently also an open PR
that will add official support for typesafe-i18n
.
When you want to dynamically access a translation, you can use the usual JavaScript syntax to access a property via a variable (myObject[myVariable]
).
- 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
- 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>
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 }
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.
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
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.
Become a sponsor ❤️ if you want to support my open source contributions.
Thanks for sponsoring my open source work!