Skip to main content

@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(), strict tr(), and withLocale.
  • Arrays supported — string-array messages return a fully formatted string[].
  • Zero-friction & fast — drop /locales/**.json; loads once with a tiny formatter cache.

GitHub
license npm install
size Discord All
Contributors

➞ 📚 Documentation: Getting started

➞ 🚀 Community: Join our Discord server

Installation​

Add the plugin to an existing Robo:

Terminal
npx robo add @robojs/i18n

Or start a new project with it preinstalled:

Terminal
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 ⇒ prefix app:
  • /locales/<locale>/shared/common.json ⇒ prefix shared/common:
  • Deeper paths keep slash-separated folders + filename (no .json), then :
    • e.g. /locales/en-US/marketing/home/hero.json ⇒ marketing/home/hero:

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 alongside nameKey.

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):
Terminal
npx i18n

Example output:

i18n:ready - Locales built in 3ms
  • Project binary: after install, you can also call:
Terminal
pnpm i18n
# or
npm run i18n

The CLI is published under the i18n binary (see bin in package.json). It’s safe to run in CI before builds.

Supported MF2 pieces (what’s parsed)​

MF2 elementExample snippetParam 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 vs string → string; any date/time → Date \| number).

How type-safety works​

On first load, the plugin:

  1. Scans /locales/**.json.
  2. Parses MessageFormat 2 messages to detect parameter kinds (number/date/time/variable usage).
  3. Emits generated/types.d.ts with:
    • type Locale = 'en-US' | 'es-ES' | ...
    • type LocaleKey = 'app:hello' | 'app:pets.count' | 'shared/common:...' | ... ← namespaced
    • type LocaleParamsMap and type 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() infer ParamsFor<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

More on GitHub​