Quick start
Set up locale files and start translating with type-safe keys.
Get translations running in three steps: create locale files, run the plugin, and call t().
Folder structure
Create a /locales directory at your project root. Each subdirectory is a locale, and each .json file becomes a namespace.
Namespace rules
Keys are automatically namespaced from the file path. The namespace is the folder path plus filename (without .json), followed by :.
| File path | Namespace prefix |
|---|---|
/locales/en-US/app.json | app: |
/locales/en-US/shared/common.json | shared/common: |
/locales/en-US/marketing/home/hero.json | marketing/home/hero: |
A key "hello" inside app.json becomes app:hello. A nested key {"greetings": {"hello": "..."}} becomes app:greetings.hello.
Creating locale files
Each locale directory should have matching JSON files. Values are strings using MessageFormat 2 syntax.
{
"hello": "Hello {$name}!",
"ping": "Pong!",
"pets.count": ".input {$count :number}\n.match $count\n one {{You have {$count} pet}}\n * {{You have {$count} pets}}"
}{
"hello": "Hola {$name}!",
"ping": "Pong!",
"pets.count": ".input {$count :number}\n.match $count\n one {{Tienes {$count} mascota}}\n * {{Tienes {$count} mascotas}}"
}Values must be strings or arrays of strings. Non-JSON files are ignored. The plugin loads everything once at startup and generates TypeScript types from what it finds.
Translating with t()
The t() function formats a message by key. Parameter types are inferred from the message syntax.
import { t } from '@robojs/i18n'
t('en-US', 'app:hello', { name: 'Robo' }) // "Hello Robo!"
t('en-US', 'app:ping') // "Pong!"
t('en-US', 'app:pets.count', { count: 3 }) // "You have 3 pets"
t('es-ES', 'app:pets.count', { count: 1 }) // "Tienes 1 mascota"import { t } from '@robojs/i18n'
t('en-US', 'app:hello', { name: 'Robo' }) // "Hello Robo!"
t('en-US', 'app:ping') // "Pong!"
t('en-US', 'app:pets.count', { count: 3 }) // "You have 3 pets"
t('es-ES', 'app:pets.count', { count: 1 }) // "Tienes 1 mascota"The first argument accepts multiple formats:
| Input | Example |
|---|---|
| Locale string | 'en-US' |
Object with locale | { locale: 'en-US' } |
Object with guildLocale | { guildLocale: 'en-US' } |
| Discord Interaction | interaction (has both locale and guildLocale) |
Strict mode with tr()
Use tr() when you want compile-time enforcement that all parameters are provided.
import { tr } from '@robojs/i18n'
tr('en-US', 'app:hello', { name: 'Robo' }) // OK — params required
// tr('en-US', 'app:hello') // compile error — missing params
tr('en-US', 'app:ping') // OK — no params neededimport { tr } from '@robojs/i18n'
tr('en-US', 'app:hello', { name: 'Robo' }) // OK — params required
// tr('en-US', 'app:hello') // compile error — missing params
tr('en-US', 'app:ping') // OK — no params neededIf a key has parameters, they're required and non-undefined. If a key has no parameters, no params argument is accepted.
Curried translator with withLocale()
Avoid passing the locale to every call by creating a curried translator.
import { withLocale } from '@robojs/i18n'
import type { ChatInputCommandInteraction } from 'discord.js'
export default (interaction: ChatInputCommandInteraction) => {
const t$ = withLocale(interaction)
const greeting = t$('app:hello', { name: 'Robo' })
const status = t$('app:ping')
return `${greeting} — ${status}`
}import { withLocale } from '@robojs/i18n'
export default (interaction) => {
const t$ = withLocale(interaction)
const greeting = t$('app:hello', { name: 'Robo' })
const status = t$('app:ping')
return `${greeting} — ${status}`
}Pass { strict: true } for strict mode:
const tr$ = withLocale('en-US', { strict: true })
tr$('app:hello', { name: 'Robo' }) // OK — params required
// tr$('app:hello') // compile errorconst tr$ = withLocale('en-US', { strict: true })
tr$('app:hello', { name: 'Robo' }) // OK — params required
// tr$('app:hello') // compile errorNested keys
Structure your locale JSON with nested objects instead of flat dotted keys.
{
"greetings": {
"hello": "Hello {$name}!"
}
}The key becomes app:greetings.hello.
t('en-US', 'app:greetings.hello', { name: 'Robo' }) // "Hello Robo!"t('en-US', 'app:greetings.hello', { name: 'Robo' }) // "Hello Robo!"A file can't contain both a literal dotted key and a nested object that flatten to the same path (e.g., "greetings.hello": "..." and {"greetings": {"hello": "..."}}). The build will throw a collision error.
Nested parameters
MF2 placeholders use dotted names like {$user.name}, but you can pass nested objects. They're flattened automatically.
{
"profile": "Hi {$user.name}! You have {$stats.count :number} points."
}t('en-US', 'app:profile', {
user: { name: 'Robo' },
stats: { count: 42 }
})
// "Hi Robo! You have 42 points."t('en-US', 'app:profile', {
user: { name: 'Robo' },
stats: { count: 42 }
})
// "Hi Robo! You have 42 points."Dotted param keys like { 'user.name': 'Robo' } also work.
Array messages
Locale values can be arrays of strings. Each element is formatted with MF2, and t()/tr() return string[].
{
"tips": ["Tip one: {$topic}", "Tip two"]
}t('en-US', 'app:tips', { topic: 'i18n' })
// ["Tip one: i18n", "Tip two"]t('en-US', 'app:tips', { topic: 'i18n' })
// ["Tip one: i18n", "Tip two"]Non-string arrays are ignored with a warning. If locales disagree on whether a key is a string or array, the return type falls back to string.
CLI
The plugin ships a CLI for regenerating types without running Robo.
npx i18nThis scans /locales, parses all JSON files, and writes generated/types.d.ts. Useful in CI pipelines or before type checking.
i18n:ready - Locales built in 3ms