Type-safe internationalization for Elm using the ICU MessageFormat
syntax — the same message format used by FormatJS
and intl-messageformat across the JavaScript ecosystem.
Write your translations as ICU messages, and a code generator turns them into
strongly-typed Elm functions. Plurals, gender selection, and locale-correct
number/date formatting are handled for you via the browser's Intl API.
-- from: "greeting": "Hello, {name}!"
Translations.greeting bundle { name = "Alice" }
--> "Hello, Alice!"
-- from: "inbox": "{count, plural, =0 {No messages} one {# message} other {# messages}}"
Translations.inbox bundle { count = 3 }
--> "3 messages".json files are portable.Intl API, so
they follow CLDR rules for every locale.reference.json ──▶ generate ──▶ Translations.elm (typed functions)
│
other locales' JSON ─────▶ loaded at runtime ─────▶ formatted stringsYou maintain one reference language file (usually English). The generator
reads it and emits a Translations module with one function per key. Other
languages are loaded from JSON at runtime and matched to the user's locale;
any key a translation is missing falls back to the reference language.
elm install gampleman/elm-translate-icuThe library needs the browser Intl API, which you pass in from JavaScript as a
flag when your app starts:
// index.js
Elm.Main.init({ flags: { intl: window.Intl } });1. Write your reference translations as ICU messages in a JSON file:
{
"greeting": "Hello, {name}!",
"inbox": "{count, plural, =0 {No messages} one {# message} other {# messages}}",
"lastSeen": "Last seen {when, date, long}",
"price": "Total: {amount, number, ::currency/USD}"
}2. Generate the typed module with the CLI:
npx elm-translate-icu en.json --module Translations --output src/Translations.elmThis produces functions whose argument records are inferred from each message:
greeting : Bundle -> { name : String } -> String
inbox : Bundle -> { count : Int } -> String
lastSeen : Bundle -> { when : Time.Posix } -> String
price : Bundle -> { amount : Float } -> String3. Initialize a bundle and call the functions:
import Icu.Bundle exposing (Bundle)
import Icu.Locale as Locale
import Intl exposing (Intl)
import Translations
type alias Model =
{ bundle : Bundle }
init : Intl -> Model
init intl =
let
locale =
Locale.fromString "en-US" |> Maybe.withDefault Locale.default
in
{ bundle = Translations.init intl locale }
view : Model -> Html msg
view model =
text (Translations.greeting model.bundle { name = "Alice" })4. Load another language at runtime and switch to it — no recompile needed:
import Icu.Bundle as Bundle
import Icu.Locale as Locale
-- `frenchJson` is the contents of fr.json fetched at runtime
switchToFrench : String -> Bundle -> Bundle
switchToFrench frenchJson bundle =
case ( Locale.fromString "fr", Bundle.decodeMessages frenchJson ) of
( Just locale, Ok ( messages, _ ) ) ->
Bundle.withLocale locale messages bundle
_ ->
bundleKeys absent from fr.json automatically fall back to the reference language.
Icu.Locale implements BCP 47 matching, so you can pick the best available
translation from a browser's language preferences:
Locale.negotiate
(List.filterMap Locale.fromString [ "en", "fr", "de" ]) -- what you ship
(List.filterMap Locale.fromString [ "fr-CA", "fr", "en" ]) -- what the user wants
|> Maybe.map Locale.toString
--> Just "fr"The full ICU MessageFormat grammar is supported:
| Feature | Example |
|---|---|
| Simple arguments | Hello, {name}! |
| Numbers | {n, number}, {n, number, ::currency/USD} |
| Dates & times | {d, date, long}, {t, time, ::Hms} |
| Select (e.g. gender) | {gender, select, male {he} female {she} other {they}} |
| Plurals | {count, plural, one {# item} other {# items}} |
| Ordinals | {n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} |
| Plural offset & exact matches | {n, plural, offset:1 =0 {none} one {#} other {# more}} |
| Nested messages | {c, plural, other {{g, select, ...} things}} |
| Quoting | '{' → literal {, '' → literal ' |
number placeholders accept ICU number skeletons as well as the integer and
percent keywords:
| Skeleton | Effect |
|---|---|
::currency/USD | currency, USD |
::.00 | exactly 2 fraction digits |
::.0# | 1–2 fraction digits |
::@@@ | 3 significant digits |
::compact-short | compact notation (e.g. 1.2K) |
::scientific / ::engineering | scientific / engineering notation |
::percent | percent |
::unit/length-meter | unit formatting |
::group-off | disable digit grouping |
::sign-always / ::sign-except-zero | sign display |
Tokens combine and later tokens win: ::currency/USD compact-short .00.
date / time placeholders accept the styles short, medium, long, and
full, as well as field-level ICU skeletons where each field's length sets its
width:
| Skeleton | Renders |
|---|---|
yMMMd | year (numeric), month (short), day (numeric) |
yyMM | 2-digit year and month |
MMMM | full month name (M→numeric, MM→2-digit, MMM→short, MMMMM→narrow) |
EEEE | full weekday name |
Hms | 24-hour time with minutes and seconds |
hh | 2-digit 12-hour hour |
Field symbols: y M L d E e c G h H K k m s a z
v V. Unknown symbols are ignored; a leading :: is optional.
elm-translate-icu <reference.json> --module <ModuleName> [--output <file>]Nested JSON objects are flattened with dots ({ "inbox": { "count": ... } }
becomes the key inbox.count). See cli/README.md for all
options and exit codes.
The generator is also available as the pure Elm function
Icu.Codegen.generate : Options -> List (String, String) -> Result _ String,
so it can be driven directly from your own Elm build tooling.
If you only need the parser — for tooling, validation, or your own formatter —
Icu.Parser and Icu.Ast are usable on their own:
Icu.Parser.parse "Hello, {name}!"
--> Ok [ Literal "Hello, ", Argument "name", Literal "!" ]elm-test # run the test suite
elm-format --yes src/ tests/ # formatBSD-3-Clause. See LICENSE.