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.jsonmarketing/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 stringstring; 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 Discordt()/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 neededt()/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