Skip to content

ccssmnn/intl

Repository files navigation

@ccssmnn/intl

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.

Features

  • ✅ 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.

Supported MessageFormat syntax

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 patterns
  • one/* 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.

Installation

pnpm add @ccssmnn/intl

Peer Dependencies:

  • React 17+ (only needed if using React integration)

Requirements:

  • Node.js 16+ or modern browser with ES2020 support

Quick Start

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"

Usage

MessageFormat 2.0 Examples

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}}`,
})

Core API

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.

Error Handling

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:

  1. Logs detailed error information to console
  2. Creates fallback messages showing the problematic key
  3. Continues execution without throwing

React integration

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.

API Reference

Core Functions

  • messages(obj) - Create typed message catalog from object literal
  • createIntl(messages, locale) - Create translation function for rendering strings
  • createIntlToParts(messages, locale) - Create formatter for manual rendering (useful for building UI framework adapters)
  • translate(base, translation) - Create type-safe translation that preserves structure
  • merge(...catalogs) - Combine multiple message catalogs (prevents key conflicts)
  • check(base, ...parts) - Verify translation coverage and merge parts

React Integration

  • createIntlForReact(messages, locale) - Returns { IntlProvider, useIntl, T, useLocale }
  • IntlProvider - Context provider for messages and locale
  • useIntl() - Hook returning translation function t(key, params?, components?)
  • T - Component for rendering messages with markup: <T k="key" params={{}} components={{}} />
  • useLocale() - Hook returning current locale string

Usage Guide

Organization workflow

  • 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 with merge(...) and validating each locale against the base via check(...).
  • Instantiate React bindings once in shared/intl/setup.ts using createIntlForReact(messagesEn, "en"); export the resulting IntlProvider, useIntl, T, and useLocale.
  • 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

Typical layout

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

Feature message slice

// ~/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",
})

Assembling the global catalog

// ~/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 */
)

React consumption

// ~/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>
	)
}

Locale switching

// 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 Side

// 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 }),
	}
}

Workflow tips

  • 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.

Development

  • pnpm install
  • pnpm test – run the Vitest suites (core and React).
  • pnpm build – emit ESM + d.ts output into dist/.

License

MIT

About

Unicode Messageformat 2 utilities for codegen free typesafety

Resources

License

Stars

Watchers

Forks