@locmod/intl
v2.0.0
Published
Lightweight react-intl alternative with minimal API.
Downloads
619
Readme
@locmod/intl
A lightweight react-intl alternative with minimal API. React 19, ESM-only, fully typed.
Installation
npm install @locmod/intlTo get the legacy global Intl.Message / Intl.MessageTranslation types, add the following triple-slash reference somewhere in your app (e.g. next-env.d.ts):
/// <reference types="@locmod/intl" />Or import the named types directly:
import type { IntlMessage, IntlMessageTranslation } from '@locmod/intl'Declaring locales
IntlMessage, IntlMessageTranslation, and IntlLocale are driven by a consumer-augmentable registry. Without augmentation, only 'en' is required and no other locale keys are allowed on message objects.
Declare the locales your app uses via module augmentation (e.g. in intl.d.ts or any .d.ts picked up by your tsconfig):
export {} // required — see note below
declare module '@locmod/intl' {
interface IntlLocales {
RequiredLocales: 'en'
OptionalLocales: 'es' | 'de'
}
}Important: the file must have at least one top-level
importorexport(theexport {}above is enough). Without it, TypeScript treatsdeclare module '@locmod/intl' { … }as an ambient module declaration that replaces the package's types, and imports likeimport { Message } from '@locmod/intl'start erroring with "has no exported member".
After that:
{ en: '…', es: '…' }typechecks;{ en: '…', fr: '…' }errors (frnot declared).IntlLocaleresolves to'en' | 'es' | 'de'everywhere —<IntlProvider locale={…}>,useIntl().locale,setLocale(…).RequiredLocalescan be a union if your messages must always provide more than one (e.g.'en' | 'fr').
Usage
import { IntlProvider, Message } from '@locmod/intl'
const messages = {
title: { en: 'Hello World' },
}
const App = () => (
<IntlProvider locale="en">
<Message value={messages.title} />
</IntlProvider>
)Message declaration
Define messages in a separate file (e.g. messages.ts) — they bundle into JS, no JSON extraction step.
const messages = {
title: { en: 'Hello World' },
content: { en: 'Hello, <b>{username}</b>' },
}<Message />
import { Message } from '@locmod/intl'
import messages from './messages'
const App = () => {
const username = 'John Doe'
const contentMessage = { ...messages.content, values: { username } }
return (
<div>
<Message value={messages.title} />
<Message value={contentMessage} />
</div>
)
}Props
value—string | IntlMessage | null | undefined. Falsy values render nothing.html—boolean. When set, the message body is parsed as HTML (and a React tree is built rather than usingdangerouslySetInnerHTML). Defaultfalse.tag— intrinsic tag name to wrap the output in. Default"span".- Any other prop is forwarded to the wrapper element. The type is inferred from
tag, so<Message tag="a" href="/x" target="_blank" />typechecks.
<Message value={message} html />
<Message tag="a" href="/docs" value={messages.docsLink} />useIntl()
useIntl() returns the active intl object from the nearest <IntlProvider>:
import { useIntl } from '@locmod/intl'
const LocaleSwitcher = () => {
const { locale, defaultLocale, setLocale } = useIntl()
return (
<select value={locale} onChange={(e) => setLocale(e.target.value)}>
<option value="en">English</option>
<option value="es">Español</option>
</select>
)
}locale— current locale.defaultLocale— fallback locale (orundefinedif none was configured on the provider).setLocale(locale)— switch the active locale; re-renders everyuseIntlconsumer.formatMessage(message, values?)— resolve a translation to a string.
useIntl().formatMessage
formatMessage is locale-aware and memoized inside the provider, so wrapping its result in useMemo isn't needed.
import { useIntl } from '@locmod/intl'
import messages from './messages'
const App = () => {
const intl = useIntl()
const title = intl.formatMessage(messages.title)
const content = intl.formatMessage(messages.content)
return (
<div>
{title}
<span dangerouslySetInnerHTML={{ __html: content }} />
</div>
)
}Prefer <Message /> when possible; use formatMessage only when you need a raw string.
defaultLocale
IntlProvider accepts an optional defaultLocale. When a message has no translation for the active locale, the provider falls back to the translation under defaultLocale instead of returning MISSED_TRANSLATION. Plural rules use the resolved locale (the language of the returned text), so a fallback to English picks English plural forms.
<IntlProvider locale="fr" defaultLocale="en">
{/* { en: 'Hello' } renders as "Hello" under the `fr` locale */}
</IntlProvider>onError is not invoked when the fallback resolves successfully; it only fires when neither locale has a translation.
onError
<IntlProvider locale="en" onError={(msg) => console.warn(msg)}>
…
</IntlProvider>onError is called on missing messages, missing translations, missing components, and missing plural rules. Unhandled translations resolve to the sentinel MISSED_TRANSLATION ({{ missed_translation }}).
Message syntax
Simple arguments
'Hello, <b>{username}</b>'
formatMessage(message, { username: 'John Doe' }) // 'Hello, <b>John Doe</b>'Plurals
'{count} {count, plural, one {product} other {products}}'
formatMessage(message, { count: 0 }) // '0 products'
formatMessage(message, { count: 1 }) // '1 product''{count, plural, one {# product} other {# products}}'
formatMessage(message, { count: 0 }) // '0 products'
formatMessage(message, { count: 1 }) // '1 product'Select
'Get free {gender, select, female {perfume} male {cologne}}'
formatMessage(message, { gender: 'female' }) // 'Get free perfume'
formatMessage(message, { gender: 'male' }) // 'Get free cologne'
formatMessage(message, { gender: null }) // 'Get free ''{gender, select, female {perfume} male {cologne} other {fragrance}}'
formatMessage(message, { gender: null }) // 'fragrance'React component arguments
A message can embed React components — <Message /> parses the formatted template into a React tree and substitutes matching tags via React.createElement. Self-closing tags (<Icon />, <Icon/>) and tags with attributes (<Link to="/x">…</Link>) both work. Component-name matching is case-sensitive against the components map.
import Icon from 'components/Icon/Icon'
import Link from 'components/Link/Link'
const message = {
en: 'Read our <Icon /> <Link to="{docsLink}">docs</Link>',
values: { docsLink: '/docs' },
components: { Icon, Link },
}
return <Message value={message} />HTML markup and components can be mixed in one message — the result is a single coherent DOM tree:
const message = {
en: '<b>Read our <Link to="/docs">docs</Link> now</b>',
components: { Link },
}Interpolated values are safe to use alongside html / components: they enter the React tree as text children (never through the HTML parser), so user-supplied input cannot inject markup — React's normal text-node escaping applies.
Migration from 1.x
- Peer dep is now React 19. Drop React 17 /
@types/react17. - Build is now ESM-only. No CJS output. Consumers must be able to
importESM (Node ≥ 18, modern bundlers, or"type": "module"packages). useIntlthrows when used outside<IntlProvider>instead of returningnull. Wrap your tree or render guards accordingly.onErroris now actually wired through the pipeline (1.x accepted it but never called it).- Named types are exported (
IntlMessage,IntlMessageTranslation, …) alongside the legacyIntl.Message*globals. Prefer the named exports in new code. - The
Intlruntime type is exported asIntlApito avoid colliding with the globalIntlnamespace. - Locales are now declared, not freeform.
IntlLocalewasstringin 1.x; it now resolves from theIntlLocalesregistry. Without augmentation, only'en'is recognized — add adeclare module '@locmod/intl'block (see Declaring locales) to register the rest.
