@eouia/intl-msg
v0.1.1
Published
Native Intl-based i18n message formatting with dictionary fallback for Node.js and browsers
Downloads
157
Maintainers
Readme
intl-msg
Native Intl-based i18n message formatting for modern Node.js, browsers, and Electron, with no runtime dependencies.
Why this exists
intl-msg started from practical localization problems that show up once a project grows beyond a simple translation table.
Typical pain points look like this:
- translation files become expensive to maintain because every regional or user-specific variant turns into a full copy
- locale-specific differences in spelling, grammar, date formatting, and number formatting leak into application code
- fallback behavior is too simple for real users who may prefer chains such as
fr-CA -> fr -> en-CA -> en - user overrides and minor-locale customizations are awkward to support cleanly
The goal of this project is to keep localization lightweight while still supporting:
- partial dictionaries instead of full duplicated dictionary snapshots
- locale-aware formatting driven by native
Intl - explicit locale fallback chains
- custom formatter behavior that can live near translation data instead of in app logic
- user or language-pack overrides without forcing the main application to ship every variation
In short, intl-msg is not just a string lookup helper. It is a small dictionary resolution and message formatting layer built around native Intl.
For the longer project rationale, see VISION.md.
Status
The package now builds from a single source file and publishes both CommonJS and ESM outputs:
- CommonJS:
dist/cjs/main.cjs - ESM:
dist/esm/main.js - Source of truth:
src/main.js
Legacy files such as commonjs/main.js, esm/main.js, and the root main.js are now thin compatibility shims. Package consumers should rely on the published package entry points.
Runtime requirements
This library is designed for modern JavaScript runtimes with full Intl support. It is not a legacy-browser compatibility build.
Minimum practical requirements:
- Node.js: 16+ recommended
- Browsers: native ESM support and modern class features, including private fields
- Electron: a modern Electron release whose bundled Chromium/Node versions satisfy the browser and Node requirements above
Required built-in Intl APIs:
Intl.getCanonicalLocalesIntl.PluralRulesIntl.DateTimeFormatIntl.RelativeTimeFormatIntl.ListFormatIntl.NumberFormat
Required JavaScript features in the runtime:
- ES modules or a bundler that can consume them
- private class fields
- optional chaining
- nullish coalescing
If your target runtime does not provide the required Intl APIs, you must inject a compatible intlPolyfill when constructing IntlMsg.
Locale input remains compatibility-friendly:
- common non-BCP47 separators such as
en_USare normalized toen-US - fallback lookup checks the full canonical locale first, then falls back through the locale base name chain such as
en-USanden
Environment support
Supported in practice means:
- Node.js: works via the published CommonJS and ESM package entry points
- Browsers: works in modern browsers through native ESM, a bundler, or the browser global build
- Electron: works when the embedded Node/Chromium runtime provides the required
IntlAPIs and language features
Not currently provided:
- a legacy ES5 build
- a UMD or IIFE browser bundle
- automatic polyfills for missing
Intlfeatures
Install
npm install @eouia/intl-msgIf you publish under a different package name, replace @eouia/intl-msg accordingly.
Usage
ESM
import IntlMsg from '@eouia/intl-msg'
const msg = IntlMsg.factory({
locales: ['en-US', 'en'],
dictionaries: {
en: {
translations: {
HELLO: 'Hello, {{name}}.',
},
},
},
})
console.log(msg.message('HELLO', { name: 'Taylor' }))CommonJS
const IntlMsg = require('@eouia/intl-msg')
const msg = new IntlMsg()
msg.addLocale(['en-US', 'en'])
msg.addDictionary({
en: {
translations: {
HELLO: 'Hello, {{name}}.',
},
},
})
console.log(msg.message('HELLO', { name: 'Taylor' }))Browser <script>
For modern browsers, the package also ships a browser global build at dist/browser/intl-msg.js.
<script src="./dist/browser/intl-msg.js"></script>
<script>
const msg = IntlMsg.factory({
locales: ['en-US', 'en'],
dictionaries: {
en: {
translations: {
HELLO: 'Hello, {{name}}.',
},
},
},
})
console.log(msg.message('HELLO', { name: 'Taylor' }))
</script>This build is intended for modern browsers. It is not a transpiled legacy-browser build.
Dictionary format
{
"en": {
"translations": {
"HELLO": "Hello, {{name}}."
},
"formatters": {
"currency": {
"format": "number",
"options": {
"style": "currency",
"currency": "USD"
}
}
}
}
}translationsmaps message keys to template stringsformattersmaps formatter names to formatter config objects
Message syntax
Plain substitution
{{name}}msg.message('HELLO', { name: 'Taylor' })
// => 'Hello, Taylor.'Formatted substitution
{{amount:currency}}msg.addDictionary({
en: {
translations: {
TOTAL: 'Total: {{amount:currency}}',
},
formatters: {
currency: {
format: 'number',
options: { style: 'currency', currency: 'USD' },
},
},
},
})
msg.message('TOTAL', { amount: 1234.5 })
// => 'Total: $1,234.50'Locale fallback
Locales are resolved using a fallback chain. For example:
en-UStriesen-US, thenenzh-Hant-TWtrieszh-Hant-TW, thenzh-Hant, thenzh
You can also provide multiple preferred locales:
msg.setLocale(['fr-CA', 'fr', 'en'])Message lookup will try each locale in order, including each locale's fallback chain, until it finds a matching translation.
Built-in formatters
The library includes these built-in formatters:
pluralRulespluralRangelistnumbernumberRangeselectdateTimedateTimeRangerelativeTimedurationhumanizedRelativeTime
Formatter overview
| Formatter | Input shape | Primary purpose |
| --- | --- | --- |
| select | scalar | Pick a string from options by value |
| pluralRules | number | Pick a string from rules by plural category |
| pluralRange | { start, end } | Pick a string from rules by plural range category |
| list | array | Render a natural-language list |
| number | number | Locale-aware number formatting |
| numberRange | { start, end } | Locale-aware numeric range formatting |
| dateTime | date-like value | Locale-aware date/time formatting |
| dateTimeRange | { start, end } | Locale-aware date/time range formatting |
| relativeTime | number | Relative time phrase with an explicit unit |
| humanizedRelativeTime | date-like value | Relative time phrase with an inferred unit |
| duration | duration record | Duration formatting via Intl.DurationFormat |
Example
msg.addDictionary({
en: {
translations: {
SUMMARY: 'Today is {{today:dateLabel}}. Total: {{amount:currency}}.',
},
formatters: {
dateLabel: {
format: 'dateTime',
options: { weekday: 'long', month: 'long', day: 'numeric' },
},
currency: {
format: 'number',
options: { style: 'currency', currency: 'USD' },
},
},
},
})
msg.message('SUMMARY', {
today: '2026-03-23',
amount: 1234.5,
})
// => 'Today is Monday, March 23. Total: $1,234.50.'Duration example
msg.addDictionary({
en: {
translations: {
ELAPSED: 'Elapsed: {{time:elapsed}}',
},
formatters: {
elapsed: {
format: 'duration',
options: { style: 'short' },
},
},
},
})
msg.message('ELAPSED', {
time: { hours: 1, minutes: 30, seconds: 5 },
})
// => 'Elapsed: 1 hr, 30 min, 5 sec'The duration formatter follows Intl.DurationFormat and expects a duration record object such as { hours: 1, minutes: 30 }.
Range examples
msg.addDictionary({
en: {
translations: {
BUDGET: 'Budget: {{amount:budget}}',
EVENT: 'Event: {{period:schedule}}',
},
formatters: {
budget: {
format: 'numberRange',
options: { style: 'currency', currency: 'USD' },
},
schedule: {
format: 'dateTimeRange',
options: { month: 'short', day: 'numeric' },
},
},
},
})
msg.message('BUDGET', {
amount: { start: 1200, end: 3400 },
})
// => 'Budget: $1,200.00 - $3,400.00'
msg.message('EVENT', {
period: { start: '2026-03-23', end: '2026-03-25' },
})
// => 'Event: Mar 23-25'The numberRange and dateTimeRange formatters expect an object with { start, end }.
Plural range example
msg.addDictionary({
en: {
translations: {
LABEL: 'Recommended for {{countText}} {{count:ticketLabel}}',
},
formatters: {
ticketLabel: {
format: 'pluralRange',
rules: {
one: 'ticket',
other: 'tickets',
},
},
},
},
})
msg.message('LABEL', {
countText: '1-3',
count: { start: 1, end: 3 },
})
// => 'Recommended for 1-3 tickets'The pluralRange formatter expects { start, end } and uses Intl.PluralRules.prototype.selectRange().
Formatter reference
select
Use select when the input value should directly choose a phrase from options.
msg.addDictionary({
en: {
translations: {
WELCOME: 'Welcome {{gender:title}} {{name}}.',
},
formatters: {
title: {
format: 'select',
options: {
female: 'Ms.',
male: 'Mr.',
other: '',
},
},
},
},
})
msg.message('WELCOME', { gender: 'female', name: 'Taylor' })
// => 'Welcome Ms. Taylor.'Expected input:
- any scalar value that can be matched against keys in
options
Fallback behavior:
- returns
options.otherwhen present - otherwise falls back to the original input value
pluralRules
Use pluralRules when a number should choose a localized term by plural category.
msg.addDictionary({
en: {
translations: {
LABEL: 'There {{count:beVerb}} {{count}} {{count:unit}}.',
},
formatters: {
beVerb: {
format: 'pluralRules',
rules: {
one: 'is',
other: 'are',
},
},
unit: {
format: 'pluralRules',
rules: {
one: 'item',
other: 'items',
},
},
},
},
})
msg.message('LABEL', { count: 2 })
// => 'There are 2 items.'Expected input:
- a number
Fallback behavior:
- returns
rules.otherwhen available - otherwise returns an empty string
pluralRange
Use pluralRange when a numeric range should choose a localized term by range category.
msg.addDictionary({
en: {
translations: {
RANGE: '{{countText}} {{count:ticketLabel}}',
},
formatters: {
ticketLabel: {
format: 'pluralRange',
rules: {
one: 'ticket',
other: 'tickets',
},
},
},
},
})
msg.message('RANGE', {
countText: '1-3',
count: { start: 1, end: 3 },
})
// => '1-3 tickets'Expected input:
{ start, end }
Fallback behavior:
- warns and falls back when
Intl.PluralRules.prototype.selectRange()is unavailable
list
Use list when you want locale-aware conjunctions such as "A, B, and C".
msg.addDictionary({
en: {
translations: {
COLORS: 'Colors: {{value:palette}}',
},
formatters: {
palette: {
format: 'list',
options: { style: 'long', type: 'conjunction' },
},
},
},
})
msg.message('COLORS', { value: ['Red', 'Blue', 'White'] })
// => 'Colors: Red, Blue, and White'Expected input:
- an array
Fallback behavior:
- returns the original value when the input is not an array
number
Use number for locale-aware numbers, currency, percent, or unit display.
msg.addDictionary({
en: {
translations: {
TOTAL: 'Total: {{amount:price}}',
},
formatters: {
price: {
format: 'number',
options: { style: 'currency', currency: 'USD' },
},
},
},
})
msg.message('TOTAL', { amount: 1234.5 })
// => 'Total: $1,234.50'Expected input:
- a number
Fallback behavior:
- validates commonly used Intl options when supported
- returns the original value when the input is not numeric or options are invalid
numberRange
Use numberRange when two numeric endpoints should be formatted as one localized range.
msg.addDictionary({
en: {
translations: {
BUDGET: 'Budget: {{amount:budget}}',
},
formatters: {
budget: {
format: 'numberRange',
options: { style: 'currency', currency: 'USD' },
},
},
},
})
msg.message('BUDGET', {
amount: { start: 1200, end: 3400 },
})
// => 'Budget: $1,200.00 - $3,400.00'Expected input:
{ start, end }
Fallback behavior:
- warns and falls back to two separately formatted values joined by
-whenformatRange()is unavailable
dateTime
Use dateTime for locale-aware date or time rendering from a date-like input.
msg.addDictionary({
en: {
translations: {
TODAY: 'Today is {{value:dateLabel}}.',
},
formatters: {
dateLabel: {
format: 'dateTime',
options: { weekday: 'long', month: 'long', day: 'numeric' },
},
},
},
})
msg.message('TODAY', { value: '2026-03-23' })
// => 'Today is Monday, March 23.'Expected input:
- a
Date, timestamp, or date-like string accepted bynew Date(...)
Fallback behavior:
- returns the original value when the input cannot be parsed as a valid date
dateTimeRange
Use dateTimeRange when two date-like values should be rendered as one localized range.
msg.addDictionary({
en: {
translations: {
EVENT: 'Event: {{period:schedule}}',
},
formatters: {
schedule: {
format: 'dateTimeRange',
options: { month: 'short', day: 'numeric' },
},
},
},
})
msg.message('EVENT', {
period: { start: '2026-03-23', end: '2026-03-25' },
})
// => 'Event: Mar 23-25'Expected input:
{ start, end }with date-like values
Fallback behavior:
- warns and falls back to two separately formatted dates joined by
-whenformatRange()is unavailable
relativeTime
Use relativeTime when the unit is known in advance.
msg.addDictionary({
en: {
translations: {
ETA: 'ETA: {{value:eta}}',
},
formatters: {
eta: {
format: 'relativeTime',
unit: 'day',
},
},
},
})
msg.message('ETA', { value: 3 })
// => 'ETA: in 3 days'Expected input:
- a number
Fallback behavior:
- validates the unit when supported
- returns the original value when the input is not numeric or the unit is invalid
humanizedRelativeTime
Use humanizedRelativeTime when you want the unit inferred from the distance to now.
msg.addDictionary({
en: {
translations: {
WHEN: 'Updated {{value:ago}}',
},
formatters: {
ago: {
format: 'humanizedRelativeTime',
},
},
},
})
msg.message('WHEN', { value: new Date(Date.now() - 2 * 60 * 60 * 1000) })
// => 'Updated 2 hours ago'Expected input:
- a date-like value
Fallback behavior:
- returns the original value when the input cannot be parsed as a valid date
duration
Use duration for explicit duration records such as hours, minutes, and seconds.
msg.addDictionary({
en: {
translations: {
ELAPSED: 'Elapsed: {{value:elapsed}}',
},
formatters: {
elapsed: {
format: 'duration',
options: { style: 'short' },
},
},
},
})
msg.message('ELAPSED', {
value: { hours: 1, minutes: 30, seconds: 5 },
})
// => 'Elapsed: 1 hr, 30 min, 5 sec'Expected input:
- an
Intl.DurationFormat-style duration record object
Fallback behavior:
- warns and falls back gracefully when
Intl.DurationFormatis unavailable
Option validation
When the runtime supports Intl.supportedValuesOf(), the library validates commonly used Intl options before constructing formatters.
Currently validated where applicable:
currencyunitcalendarnumberingSystem
If an option is invalid, the formatter warns through the configured logger and falls back gracefully instead of relying only on a constructor exception.
Parts-aware post-processing
Any formatter can optionally pass its result through one registered post-formatter as a second stage.
There is no recursive formatter pipeline: format runs first, then postFormat may run once.
Supported built-in formatters currently provide parts when available:
listnumbernumberRangedateTimedateTimeRangerelativeTime
The post-formatter receives a context object including:
value: the built-in formatter's default string resultparts: the result offormatToParts()orformatRangeToParts()when supportedrawValue: the original unformatted input valueformat: the built-in formatter name that ran first
For custom primary formatters, postFormat still works, but parts is only populated when the first stage formatter collected them.
Example:
msg.registerFormatter('markCurrency', ({ value, parts }) => {
const currency = parts.find((part) => part.type === 'currency')?.value ?? ''
return `${value} [${currency}]`
})
msg.addDictionary({
en: {
translations: {
TOTAL: 'Total: {{amount:price}}',
},
formatters: {
price: {
format: 'number',
options: { style: 'currency', currency: 'USD' },
postFormat: 'markCurrency',
},
},
},
})
msg.message('TOTAL', { amount: 1234.5 })
// => 'Total: $1,234.50 [$]'Custom formatters
Register a formatter by name, then reference it from dictionary formatter definitions:
msg.registerFormatter('capitalize', ({ value }) => {
const text = value == null ? '' : String(value)
return text ? text[0].toUpperCase() + text.slice(1).toLowerCase() : text
})
msg.addDictionary({
en: {
translations: {
TITLE: 'Welcome, {{name:titleCase}}.',
},
formatters: {
titleCase: {
format: 'capitalize',
},
},
},
})
msg.message('TITLE', { name: 'tAYLOR' })
// => 'Welcome, Taylor.'Custom formatter callbacks receive a single config object. Common fields include:
localesvalueoptions- any additional formatter-specific properties from the dictionary config
API
new IntlMsg(options?)
Creates an instance.
Supported options:
logverboseintlPolyfill
Example:
const msg = new IntlMsg({ verbose: false })IntlMsg.factory(options?)
Convenience constructor. In addition to the constructor options, it also accepts:
localesdictionaries
Example:
const msg = IntlMsg.factory({
locales: ['en-US', 'en'],
dictionaries: {
en: { translations: { HELLO: 'Hello' } },
},
})
msg.message('HELLO')
// => 'Hello'addLocale(locales)
Adds one locale or an array of locales.
msg.addLocale(['en-US', 'de'])
msg.getLocale()
// => ['en-US', 'de']setLocale(locales)
Replaces the current locale list.
msg.setLocale(['fr-CA', 'fr', 'en'])
msg.getLocale()
// => ['fr-CA', 'fr', 'en']getLocale()
Returns the current locale list.
addDictionary(dictionaryJson)
Merges dictionary data into the current instance.
msg.addDictionary({
en: {
translations: {
HELLO: 'Hello, {{name}}.',
},
},
})
msg.message('HELLO', { name: 'Taylor' })
// => 'Hello, Taylor.'getDictionary(locale)
Returns the Dictionary instance for a locale, or null.
msg.getDictionary('en')
// => Dictionary instance or nullgetDictionaryNames()
Returns the registered locale names.
msg.getDictionaryNames()
// => ['en', 'en-US']addTermToDictionary(locale, key, value)
Adds or replaces a translation term for a locale.
msg.addTermToDictionary('en', 'BYE', 'Goodbye')
msg.message('BYE')
// => 'Goodbye'getTermFromDictionary(locale, key)
Returns a term value, or undefined.
msg.getTermFromDictionary('en', 'HELLO')
// => 'Hello, {{name}}.'getRawMessage(key, locales?)
Returns the untranslated template string selected by locale lookup.
msg.getRawMessage('HELLO')
// => 'Hello, {{name}}.'message(key, values?)
Formats and returns the final message string.
msg.message('HELLO', { name: 'Taylor' })
// => 'Hello, Taylor.'registerFormatter(name, fn)
Registers a custom formatter callback.
msg.registerFormatter('capitalize', ({ value }) => {
const text = value == null ? '' : String(value)
return text ? text[0].toUpperCase() + text.slice(1).toLowerCase() : text
})Dictionary formatter configs may also set postFormat to the name of a registered formatter. Only two stages are supported: format, then postFormat.
Development
Install dependencies and run tests:
npm testTests currently build the package first, then run Mocha with nyc coverage.
Publishing
Manual publish:
npm publish --access publicTrusted publishing via GitHub Actions is also configured in publish.yml.
Current workflow behavior:
- pushes of tags matching
v*trigger the publish workflow - the workflow runs
npm ci,npm test, and thennpm publish --access public - npm trusted publishing must be configured on npmjs.com for this repository and workflow file
To enable trusted publishing on npm:
- Open your package settings on npmjs.com
- Add a trusted publisher for GitHub Actions
- Use GitHub owner
eouia - Use repository
intl-msg - Use workflow file
publish.yml
After that, publishing a new release is:
git tag v0.1.0
git push origin v0.1.0Production use
intl-msg is usable in real applications today, especially when you want:
- partial dictionaries
- locale-aware formatting driven by translation data
- explicit fallback behavior
- application-controlled dictionary loading
It is a good fit when:
- your app can decide where dictionaries live
- you want to merge default dictionaries, language packs, and user overrides
- you want to stay close to native
Intlbehavior
Things to keep in mind:
- dictionary discovery is application-owned
- the optional
composeandloadershelpers are intentionally small - modern runtimes are assumed
- this is not an ICU MessageFormat replacement
For runtime language switching, prefer composing a fresh dictionary set and creating a fresh IntlMsg instance instead of mutating one long-lived instance in place.
Optional composition helper
The package also provides an optional composition helper for building one merged dictionary from a priority plan:
import composeDictionaries from '@eouia/intl-msg/compose'
const dictionaries = await composeDictionaries(
[
{ locale: 'en', source: 'default' },
{ locale: 'en-US', source: 'langpack' },
{ locale: 'en-CA', source: 'user' },
],
async ({ locale, source }) => {
// Application-specific loading logic goes here.
// Return a partial dictionary object or null.
}
)This helper is intentionally small and optional. It does not replace the core IntlMsg API.
Optional loader helpers
The package also provides optional strict helpers via intl-msg/loaders:
import { createMemoryLoader, createFetchLoader, createPathLoader } from '@eouia/intl-msg/loaders'These helpers are intentionally narrow:
- they work well for simple, conventional layouts
- they do not try to discover arbitrary custom dictionary locations
- applications can override URL/path resolution through callbacks
Examples:
const memoryLoader = createMemoryLoader(registry)
const fetchLoader = createFetchLoader({
resolveUrl: ({ locale, source }) => `/dictionaries/${source}/${locale}.json`,
})
const pathLoader = createPathLoader({
resolvePath: ({ locale, source }) => `./dictionaries/${source}/${locale}.json`,
readFile: fs.promises.readFile,
})Node.js path example:
import IntlMsg from 'intl-msg'
import composeDictionaries from 'intl-msg/compose'
import { createPathLoader } from 'intl-msg/loaders'
import { readFile } from 'node:fs/promises'
const loader = createPathLoader({
readFile,
resolvePath: ({ locale, source }) =>
`${process.cwd()}/dictionaries/${source}/${locale}.json`,
})
const dictionaries = await composeDictionaries(
[{ locale: 'en-US', source: 'default' }],
loader
)
const msg = IntlMsg.factory({
locales: ['en-US', 'en'],
dictionaries,
})
console.log(msg.message('HELLO'))