@robojs/i18n
Type-safe i18n for Robo.js with MessageFormat 2 (MF2).
Drop JSON files in /locales
, get strongly-typed namespaced keys & parameters, and format messages at runtime with t()
or the strict tr()
—no custom build steps required.
- Strong types from your JSON — MF2 infers keys & param types.
- Runtime formatting anywhere (Discord & beyond) with
t()
, stricttr()
, andwithLocale
. - Arrays supported — string-array messages return a fully formatted
string[]
. - Zero-friction & fast — drop
/locales/**.json
; loads once with a tiny formatter cache.
➞ 📚 Documentation: Getting started
➞ 🚀 Community: Join our Discord server
Installation
Add the plugin to an existing Robo:
npx robo add @robojs/i18n
Or start a new project with it preinstalled:
npx create-robo <project-name> -p @robojs/i18n
Folder structure
Put message files under /locales/<locale>/**/*.json
.
Keys are automatically namespaced from the file path:
/locales/<locale>/app.json
⇒ prefixapp:
/locales/<locale>/shared/common.json
⇒ prefixshared/common:
- Deeper paths keep slash-separated folders + filename (no
.json
), then:
- e.g.
/locales/en-US/marketing/home/hero.json
⇒marketing/home/hero:
- e.g.
Example tree:
/locales
/en-US
app.json
/es-ES
app.json
en-US/app.json
{
"hello": "Hello {$name}!",
"pets.count": ".input {$count :number}\n.match $count\n one {{You have {$count} pet}}\n * {{You have {$count} pets}}"
}
es-ES/app.json
{
"hello": "¡Hola {$name}!",
"pets.count": ".input {$count :number}\n.match $count\n one {{Tienes {$count} mascota}}\n * {{Tienes {$count} mascotas}}"
}
Only string values are used. Non-JSON files are ignored. The plugin loads everything once, keeps it in state, and generates types from what it finds.
Runtime usage (t
)
t(localeLike, key, params?)
formats a message right now. The params
type is inferred from key
.
import { t } from '@robojs/i18n'
// Accepts a string, a Discord Interaction, or any { locale } / { guildLocale } object
const locale = 'en-US' as const
t(locale, 'app:hello', { name: 'Robo' }) // "Hello Robo!"
t(locale, 'app:pets.count', { count: 3 }) // "You have 3 pets"
locale
can be:
'en-US'
(string){ locale: 'en-US' }
{ guildLocale: 'en-US' }
Strict runtime usage (tr
)
tr(localeLike, key, ...args)
is a strict variant of t
:
- If the message has parameters, they are required and non-undefined.
- If the message has no parameters, you can omit the params object.
import { tr } from '@robojs/i18n'
const locale = 'en-US' as const
tr(locale, 'app:hello', { name: 'Robo' }) // ✅ required
// tr(locale, 'app:hello') // ❌ compile-time error
tr(locale, 'app:ping') // ✅ key with no params
Cleaner calls with withLocale
Avoid threading locale
around:
import { withLocale } from '@robojs/i18n'
import type { ChatInputCommandInteraction } from 'discord.js'
export default (interaction: ChatInputCommandInteraction) => {
const t$ = withLocale(interaction)
return t$('app:hello', { name: 'Robo' })
}
Strict variant
Pass { strict: true }
to get a curried strict translator (tr$
) that enforces required params like tr
:
import { withLocale } from '@robojs/i18n'
const tr$ = withLocale('en-US', { strict: true })
tr$('app:hello', { name: 'Robo' }) // ✅ required
// tr$('app:hello') // ❌ compile-time error
tr$('app:ping') // ✅ key with no params
Nesting
You can nest keys inside JSON and provide nested objects for params.
Nested keys
Instead of writing flat keys, you may structure your locale file:
// /locales/en-US/app.json
{
"greetings": {
"hello": "Hello {$name}!"
}
}
The key becomes app:greetings.hello
.
⚠️ Collision rule: a file cannot contain both a literal dotted key and a nested object that flatten to the same key (e.g.,
"greetings.hello": "…"
and{ "greetings": { "hello": "…" } }
). The build will error with a clear message.
Nested parameter objects
Although MF2 placeholders look flat ({$user.name}
), you can pass nested objects and they’ll be flattened for you.
// /locales/en-US/app.json
{
"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."
Prefer objects for readability; dotted param keys like
{ 'user.name': 'Robo' }
also work if you need them.
Arrays
Locale values can be arrays of strings. Each element is formatted with MF2, and t()
/tr()
return a string[]
for that key.
/locales/en-US/shared/common.json
{
"arr": ["One {$n :number}", "Two"]
}
/locales/es-ES/shared/common.json
{
"arr": ["Uno {$n :number}", "Dos"]
}
t('en-US', 'shared/common:arr', { n: 7 }) // ["One 7", "Two"]
t('es-ES', 'shared/common:arr', { n: 3 }) // ["Uno 3", "Dos"]
Arrays support MF2 placeholders per element, including
:number
,:date
,:time
, and:datetime
. Non-string arrays are ignored.
Discord slash commands
createCommandConfig
🎮
Import createCommandConfig
from @robojs/i18n
instead of robo.js
to define slash command metadata with i18n keys. The plugin will fill in names and descriptions for all locales at runtime.
import { createCommandConfig, t } from '@robojs/i18n'
import type { ChatInputCommandInteraction } from 'discord.js'
import type { CommandOptions } from 'robo.js'
export const config = createCommandConfig({
nameKey: 'commands:ping.name',
descriptionKey: 'commands:ping.desc',
options: [
{
type: 'string',
name: 'text',
nameKey: 'commands:ping.arg.name',
descriptionKey: 'commands:ping.arg.desc'
}
]
} as const)
export default (interaction: ChatInputCommandInteraction, options: CommandOptions<typeof config>) => {
const user = { name: options.text ?? 'Robo' }
return t(interaction, 'commands:hey', { user })
}
/locales/en-US/commands.json
{
"hey": "Hey there, {$user.name}!",
"ping": {
"name": "ping",
"desc": "Measure latency",
"arg": {
"name": "text",
"desc": "Optional text to include"
}
}
}
For options,
name
is still required (helps TS inference) and should be provided alongsidenameKey
.
Performance
We keep a small in-memory cache of compiled MessageFormat
instances, keyed by (locale, key, message)
. This avoids reparsing strings on repeated calls and fits apps with a few hundred keys per locale. You can clear it during tests or hot-reload:
import { clearFormatterCache } from '@robojs/i18n'
clearFormatterCache()
CLI (i18n)
This package ships a tiny CLI to build & refresh locale types and caches.
- Run once (auto-detects your
/locales
and generates types):
npx i18n
Example output:
i18n:ready - Locales built in 3ms
- Project binary: after install, you can also call:
pnpm i18n
# or
npm run i18n
The CLI is published under the
i18n
binary (seebin
inpackage.json
). It’s safe to run in CI before builds.
Supported MF2 pieces (what’s parsed)
MF2 element | Example snippet | Param type inferred |
---|---|---|
variable | {$name} | string |
number | {$count :number} | number |
match (num) | .input {$count :number}\n.match $count\n one {{…}}\n * {{…}} | number |
date | {$ts :date} | Date | number |
time | {$ts :time} | Date | number |
datetime | {$ts :datetime} | Date | number |
If different locales disagree on a param’s kind, the type safely widens (e.g.,
number
vsstring
→string
; any date/time →Date \| number
).
How type-safety works
On first load, the plugin:
- Scans
/locales/**.json
. - Parses MessageFormat 2 messages to detect parameter kinds (number/date/time/variable usage).
- Emits
generated/types.d.ts
with:type Locale = 'en-US' | 'es-ES' | ...
type LocaleKey = 'app:hello' | 'app:pets.count' | 'shared/common:...' | ...
← namespacedtype LocaleParamsMap
andtype ParamsFor<K>
Formatting is done by messageformat
at runtime, with a small cache of compiled formatters to reduce CPU work across calls.
Notes & FAQs
- Works outside Discord —
t()
/tr()
are plain functions. Use them anywhere you can pass a locale string or object. - Missing locale or key → throws an error (fast fail).
- Nested MF2 (e.g.,
.match
blocks) is traversed correctly. - No manual type imports needed —
t()
/tr()
inferParamsFor<K>
from your keys. - Namespaced keys are required — always use the
<folders>/<file>:
prefix (e.g.,app:hello
,shared/common:greet
).
Got questions?
If you have any questions or need help with this plugin, join our Discord — we’re friendly and happy to help!
➞ 🚀 Community: Join our Discord server