A internationalization utility that does not require extraction/codegen, is super typesafe and works great with coding agents. Basically just type and react utilities built on top of the official Unicode MessageFormat 2.0 formatter.
- ✅ No code generation / message extraction: author messages and use them immediately.
- ✅ Unicode MessageFormat 2 syntax via
messageformat
(the only dependency). - ✅ React utilities for providers, hooks, and components with markup rendering.
- ✅ Pairs seamlessly with coding agents—messages due to familiar syntax and friendly type errors.
- ✅ Works equally well on the server e.g. notifications and emails.
Runtime support: The library delegates parsing and formatting to messageformat@4
for full MessageFormat 2.0 support.
Type-level support: The TypeScript types currently validate a focused subset of the specification:
✅ Parameters:
{$param}
- String parameters{$param :number}
- Number parameters{$param :datetime}
- Date/DateTime parameters{$param :date}
- Date parameters{$param :time}
- Time parameters
✅ Markup:
{#tag}content{/tag}
- Simple markup spans (no attributes)- Nested markup tags
- Type-safe component mapping in React
✅ Selection/Pluralization:
.input {$var} .match $var
patternsone
/*
plural rules- Custom selection branches
❌ Not type-validated (but work at runtime):
- Parameter options like
style=currency
- Markup tag attributes
- Complex function calls
- Other MessageFormat 2.0 features
The type system focuses on the most common i18n patterns while letting you use advanced features when needed.
pnpm add @ccssmnn/intl
Peer Dependencies:
- React 17+ (only needed if using React integration)
Requirements:
- Node.js 16+ or modern browser with ES2020 support
import { messages, createIntl } from "@ccssmnn/intl"
// 1. Define your messages
const copy = messages({
greeting: "Hello {$name}!",
count: "You have {$num :number} items",
})
// 2. Create translator
const t = createIntl(copy, "en")
// 3. Use it
t("greeting", { name: "World" }) // "Hello World!"
t("count", { num: 42 }) // "You have 42 items"
Common patterns with type validation:
const messages = messages({
// Basic interpolation
greeting: "Hello {$name}!",
// Typed parameters (type-validated)
price: "Price: {$amount :number}",
date: "Today is {$today :datetime}",
time: "Meeting at {$when :time}",
// Pluralization
items:
".input {$count :number} .match $count one {{one item}} * {{{$count} items}}",
// Selection
status:
".input {$role} .match $role admin {{Welcome admin}} user {{Hello user}} * {{Hi there}}",
// Markup tags
welcome:
"Welcome {#bold}{$name}{/bold}! Click {#link}here{/link} to continue",
// Complex nested structure
cart: `.input {$count :number} {$hasDiscount}
.match $count $hasDiscount
0 true {{Your cart is empty, but you have a discount!}}
0 * {{Your cart is empty}}
one true {{You have one item with discount applied}}
one * {{You have one item}}
* true {{You have {$count} items with discount applied}}
* * {{You have {$count} items}}`,
})
import { messages, createIntl, translate } from "@ccssmnn/intl"
// annotates `base` with the messages types
let base = messages({
greeting: "Hello {$name}!",
count: "You have {$num :number} items",
})
// guarantees on a type level that `german` has the same message signature as `base`
let german = translate(base, {
greeting: "Hallo {$name}!",
count: "Du hast {$num :number} Elemente",
})
// create typesafe message consumption utility!
let t = createIntl(german, "de")
t("greeting", { name: "Carl" })
// → "Hallo Carl!"
All helpers are strongly typed. For example, t
refuses calls without the name
parameter, and translate
enforces that translations keep the same params/markup as the base copy.
The library provides graceful error handling for malformed messages:
const problematic = messages({
valid: "Hello {$name}!",
broken: "Invalid syntax {{{", // Malformed message
})
const t = createIntl(problematic, "en")
t("valid", { name: "Carl" }) // "Hello Carl!"
t("broken") // "❌: broken" (fallback with key name)
// Console shows table with error details
When MessageFormat compilation fails, the library:
- Logs detailed error information to console
- Creates fallback messages showing the problematic key
- Continues execution without throwing
import { createIntlForReact, messages } from "@ccssmnn/intl"
const copy = messages({
signIn: "Hey {$name}! {#link}Sign in here{/link}",
})
const { IntlProvider, useIntl, T } = createIntlForReact(copy, "en")
function Toolbar() {
const t = useIntl()
return <p>{t("signIn", { name: "Carl" })}</p>
}
function Entry() {
return (
<IntlProvider>
<Toolbar />
<T
k="signIn"
params={{ name: "Carl" }}
components={{ link: ({ children }) => <a href="/login">{children}</a> }}
/>
</IntlProvider>
)
}
The hook exposes the same typed t
function as the core API, so TypeScript will flag missing params/markup right in React components. The <T>
component uses formatToParts
to let you supply React components for markup tags.
messages(obj)
- Create typed message catalog from object literalcreateIntl(messages, locale)
- Create translation function for rendering stringscreateIntlToParts(messages, locale)
- Create formatter for manual rendering (useful for building UI framework adapters)translate(base, translation)
- Create type-safe translation that preserves structuremerge(...catalogs)
- Combine multiple message catalogs (prevents key conflicts)check(base, ...parts)
- Verify translation coverage and merge parts
createIntlForReact(messages, locale)
- Returns{ IntlProvider, useIntl, T, useLocale }
IntlProvider
- Context provider for messages and localeuseIntl()
- Hook returning translation functiont(key, params?, components?)
T
- Component for rendering messages with markup:<T k="key" params={{}} components={{}} />
useLocale()
- Hook returning current locale string
- Organize message catalogues per feature (e.g.
~/intl/messages.todos.ts
) to keep related messages colocated and make them easier to work with. - Assemble the global catalogue in
~/intl/messages.ts
by merging feature slices withmerge(...)
and validating each locale against the base viacheck(...)
. - Instantiate React bindings once in
shared/intl/setup.ts
usingcreateIntlForReact(messagesEn, "en")
; export the resultingIntlProvider
,useIntl
,T
, anduseLocale
. - Reuse the framework-agnostic helpers (
createIntl
,createIntlToParts
) for server tasks such as localized push notifications.
Benefits of this approach:
- Smaller, focused message modules are easier to navigate and modify
- Related messages stay close to the features that use them
- Works better with coding agents that can understand and modify specific feature contexts
- Prevents merge conflicts when multiple features add messages simultaneously
shared/
intl/
setup.ts # configured React exports
messages.ts # merges base + locale slices
messages.todos.ts # per-feature catalogue (base + translate)
messages.*.ts # additional slices
server/
notifications/
send-push.ts # imports createIntl() for localized payloads
// ~/intl/messages.todos.ts
import { messages, translate } from "@ccssmnn/intl"
export { baseTodoMessages, deTodoMessages }
let baseTodoMessages = messages({
"todos.header": "Tasks for {$name}",
"todos.remaining": "You have {#strong}{$count :number}{/strong} remaining",
})
let deTodoMessages = translate(baseTodoMessages, {
"todos.header": "Aufgaben für {$name}",
"todos.remaining": "Du hast noch {#strong}{$count :number}{/strong} übrig",
})
// ~/intl/messages.ts
import { merge, check } from "@ccssmnn/intl"
import { baseTodoMessages, deTodoMessages } from "./messages.todos"
// import other feature slices...
export { messagesEn, messagesDe }
let messagesEn = merge(
baseTodoMessages
/* other base slices */
)
let messagesDe = check(
messagesEn,
deTodoMessages
/* other locale slices */
)
// ~/intl/setup.ts
import { createIntlForReact } from "@ccssmnn/intl/react"
import { messagesEn } from "./messages"
// create the provider with the default messages and locale
export const { IntlProvider, useIntl, T, useLocale } = createIntlForReact(
messagesEn,
"en"
)
// ui/components/todo-header.tsx
import { useIntl, T } from "~/intl/setup"
export function TodoHeader({
username,
remaining,
}: {
username: string
remaining: number
}) {
const t = useIntl()
return (
<header>
<h1>{t("todos.header", { name: username })}</h1>
<T
k="todos.remaining"
params={{ count: remaining }}
components={{
strong({ children }) {
return <strong>{children}</strong>
},
}}
/>
</header>
)
}
// app/root.tsx
import { IntlProvider } from "~/intl/setup"
import { messagesEn, messagesDe } from "~/intl/messages"
const catalogs = {
en: messagesEn,
de: messagesDe,
}
export function App({ locale }: { locale: keyof typeof catalogs }) {
return (
<IntlProvider messages={catalogs[locale]} locale={locale}>
{/* ... */}
</IntlProvider>
)
}
// server/notifications/send-push.ts
import { createIntl } from "@ccssmnn/intl"
import { messagesEn, messagesDe } from "~/intl/messages"
const catalogs = {
en: messagesEn,
de: messagesDe,
}
export function buildTodoNotification(
locale: keyof typeof catalogs,
taskTitle: string,
dueAt: Date
) {
const t = createIntl(catalogs[locale], locale)
return {
title: t("notifications.todo.title", { title: taskTitle }),
body: t("notifications.todo.body", { dueAt }),
}
}
- Add new message keys to the base slice first; compilation fails until translations match, keeping locales synchronised.
- Use
<T>
only when markup tags are needed;useIntl()
suffices for plain strings. - Run
pnpm test
(with type-checking enabled) after modifying catalogues to catch ICU or markup mismatches immediately. - When extracting into a shared library, export the same primitives (
messages
,merge
,translate
,check
,createIntl
,createIntlForReact
) so both client and server consumers retain the ergonomics.
pnpm install
pnpm test
– run the Vitest suites (core and React).pnpm build
– emit ESM + d.ts output intodist/
.