This document is a developer-oriented reference for Unicode MessageFormat 2 (MF2) syntax as implemented in ex_cldr_messages. It covers the grammar, semantics, and built-in functions available when writing MF2 messages.
MF2 is the successor to the legacy ICU Message Format. It provides a clearer, more extensible syntax with explicit declarations, a function registry, pattern matching, and markup support. A translator's guide helps with onboarding the format.
Message Structure
Every MF2 message is either a simple message or a complex message.
Simple Messages
A simple message is plain text with optional placeholders. It cannot start with . or {{.
Hello, world!
Hello, {$name}!
Today is {$date :date style=medium}.Simple messages are the most common form. Text is literal; placeholders are enclosed in { }.
Complex Messages
A complex message starts with declarations (.input, .local) or a body keyword (.match, {{). The output pattern is always wrapped in {{ }} (a quoted pattern) or defined by .match variants.
.input {$name :string}
{{Hello, {$name}!}}.input {$count :number}
.local $greeting = {|Welcome|}
.match $count
1 {{You have one item, {$greeting}.}}
* {{You have {$count} items, {$greeting}.}}The real power of the format comes when multiple matches are required. In this case the translator can more easily understand the different combinations of matches and more easily see the intent behind the messages to be translated.
.input {$pronoun :string}
.input {$count :number}
.match $pronoun $count
he one {{He has {$count} notification.}}
he * {{He has {$count} notifications.}}
she one {{She has {$count} notification.}}
she * {{She has {$count} notifications.}}
* one {{They have {$count} notification.}}
* * {{They have {$count} notifications.}}Here the input variables are clear; the various combinations of them is clear and the resulting messages are clear.
Variables
Variables are prefixed with $ and refer to values passed as bindings at format time.
{$userName}
{$count :number}Variable names
Variable names are case sensitive: $name and $Name are different variables.
Variable names follow MF2 naming rules: they start with a letter, _, or +, followed by letters, digits, -, or ..
When formatting, bindings can be provided as a map with string keys or atom keys:
iex> Cldr.Message.format("{{Hello, {$name}!}}", %{"name" => "Alice"})
{:ok, "Hello, Alice!"}
iex> Cldr.Message.format("{{Hello, {$name}!}}", [name: "Alice"])
{:ok, "Hello, Alice!"}Literals
Quoted Literals
Quoted literals are enclosed in | | and can contain any text. Use \\ to escape \ and \| to escape | within quoted literals.
{|Hello, world!|}
{|special chars: \| and \\|}Unquoted Literals
Unquoted literals are bare names or number literals used directly.
{hello}
{42}
{3.14}Number Literals
Number literals follow the pattern [-] digits [. digits] [e [+-] digits]:
42
-7
3.14
1.5e3Expressions
An expression is enclosed in { } and consists of an optional operand, an optional function annotation, and optional attributes.
{$variable} Variable reference
{$count :number} Variable with function
{|literal text| :string} Literal with function
{:datetime} Function-only (no operand)
{$x :number minimumFractionDigits=2} Function with options
{$name @translatable} Variable with attributeGeneral Form
{ [operand] [:function [options...]] [@attribute...] }Where:
- operand is a variable (
$name), quoted literal (|text|), or number literal (42) - function is
:functionNameoptionally followed by space-separatedkey=valueoptions - attributes are
@nameor@name=valuemetadata annotations
Functions
Functions transform or format values. They are invoked with :functionName syntax inside an expression.
:string
String coercion. Converts the value to a string representation. Numbers, atoms, and other types that implement the String.Chars protocol are coerced to their string form.
{$name :string}iex> Cldr.Message.format("{{The answer is {$x :string}.}}", %{"x" => 42}, formatter_backend: :elixir)
{:ok, "The answer is 42."}
iex> Cldr.Message.format("{{Status: {$flag :string}}}", %{"flag" => true}, formatter_backend: :elixir)
{:ok, "Status: true"}:number
Locale-aware number formatting using Cldr.Number.
{$count :number}
{$price :number minimumFractionDigits=2}
{$total :number minimumFractionDigits=1 maximumFractionDigits=4}
{$plain :number useGrouping=never}
{$arabic :number numberingSystem=arab}| Option | Values | Description |
|---|---|---|
minimumFractionDigits | integer (e.g., 2) | Minimum decimal places — pads with trailing zeros. |
maximumFractionDigits | integer (e.g., 4) | Maximum decimal places — rounds or truncates beyond this. |
useGrouping | auto (default), always, min2, never | Controls grouping separators (e.g., commas). never suppresses them. min2 groups only when 2+ digits in the highest group. |
numberingSystem | latn (default), arab, deva, etc. | Selects a numbering system. Must be valid for the locale. |
select | plural (default), ordinal, exact | Controls .match key resolution: plural uses cardinal plural categories, ordinal uses ordinal categories, exact uses literal value matching only. |
These options also apply to :integer, :percent, :currency, and :unit functions.
:integer
Formats a number as an integer (truncates any decimal part).
{$count :integer}:percent
Formats a number as a percentage.
{$ratio :percent}A value of 0.85 formats as 85% (locale-dependent).
:currency
Formats a number as a currency amount.
{$amount :currency currency=USD}
{$amount :currency currency=EUR currencyDisplay=narrowSymbol}
{$amount :currency currency=USD currencySign=accounting}| Option | Values | Description |
|---|---|---|
currency | ISO 4217 code (e.g., USD, EUR) | The currency to format with (required) |
currencyDisplay | symbol (default), narrowSymbol, code | How to display the currency identifier |
currencySign | standard (default), accounting | accounting uses parentheses for negative values |
Note: currencyDisplay=name is not currently supported.
Money struct bindings
When the bound value is a Money.t struct (from the ex_money package), the currency, amount, and formatting options are derived automatically from the struct:
The
currencyis taken from the struct's:currencyfield unless an explicitcurrencyoption is provided in the message.The numeric amount is taken from the struct's
:amountfield.Any
:format_optionsstored on the struct (e.g.,currency_symbol: :iso) are applied as base formatting options. Options specified in the MF2 message (e.g.,currencyDisplay,currencySign) take precedence over the struct's format options.
This means a Money.t value can be formatted without specifying a currency option:
{$price :currency}:unit
Formats a number with a measurement unit. Requires the ex_cldr_units package.
{$distance :unit unit=kilometer}
{$weight :unit unit=kilogram unitDisplay=short}
{$temp :unit unit=fahrenheit unitDisplay=narrow}| Option | Values | Description |
|---|---|---|
unit | CLDR unit identifier (e.g., kilometer, kilogram) | The unit to format with (required) |
unitDisplay | long, short, narrow | How to display the unit name (default: long) |
Cldr.Unit struct bindings
When the bound value is a Cldr.Unit.t struct, the unit and value are derived automatically from the struct. Any :format_options stored on the struct are automatically merged by Cldr.Unit.to_string/2.
The
unitis taken from the struct's:unitfield unless an explicitunitoption is provided in the message.The numeric value is taken from the struct's
:valuefield.
This means a Cldr.Unit.t value can be formatted without specifying a unit option:
{$distance :unit}:date
Formats a date value. Requires the ex_cldr_dates_times package. Accepts ISO 8601 string literals (e.g., |2006-01-02|), Date, NaiveDateTime, or DateTime structs.
{$when :date}
{$when :date style=short}
{|2006-01-02| :date length=long}| Option | Values | Description |
|---|---|---|
style | short, medium, long, full | Date format style (default: medium) |
length | short, medium, long, full | Alias for style |
:time
Formats a time value. Requires the ex_cldr_dates_times package. Accepts ISO 8601 datetime string literals (e.g., |2006-01-02T15:04:06|), Time, NaiveDateTime, or DateTime structs.
{$when :time}
{$when :time style=short}
{|2006-01-02T15:04:06| :time precision=second}| Option | Values | Description |
|---|---|---|
style | short, medium, long, full | Time format style (default: medium) |
precision | second, minute | Time precision (second maps to medium, minute maps to short) |
:datetime
Formats a datetime value. Requires the ex_cldr_dates_times package. Accepts ISO 8601 string literals (e.g., |2006-01-02T15:04:06|), NaiveDateTime, DateTime, or Date structs.
{$when :datetime}
{$when :datetime style=long}
{$when :datetime dateStyle=long timeStyle=short}
{|2006-01-02T15:04:06| :datetime dateLength=long timePrecision=second}| Option | Values | Description |
|---|---|---|
style | short, medium, long, full | Sets both date and time format style (default: medium) |
dateStyle | short, medium, long, full | Date portion format style |
dateLength | short, medium, long, full | Alias for dateStyle |
timeStyle | short, medium, long, full | Time portion format style |
timePrecision | second, minute | Time precision (second maps to medium, minute maps to short) |
When dateStyle/timeStyle are used independently, the other defaults to the locale's :medium format.
Function Options
Function options are key=value pairs separated by whitespace after the function name. Values can be quoted literals, unquoted literals, number literals, or variable references.
{$n :number minimumFractionDigits=2}
{$n :number style=|percent|}
{$n :number minimumFractionDigits=$precision}Declarations
Declarations appear at the start of a complex message, before the body. They bind or annotate variables.
.input
Declares an external variable and optionally applies a function to it. The variable must be provided in the bindings at format time.
.input {$count :number}This declares that $count is expected as input and should be formatted using :number. Subsequent references to $count in the message body will use the formatted value.
.local
Binds a new local variable to an expression. The right-hand side can reference other variables or use literals.
.local $formatted_name = {$name :string}
.local $greeting = {|Hello|}
.local $doubled = {$count :number minimumFractionDigits=2}Local variables are available in the message body and in subsequent declarations.
Quoted Patterns
The output of a complex message is a quoted pattern: text and placeholders wrapped in {{ }}.
.input {$name :string}
{{Hello, {$name}!}}Quoted patterns can contain:
- Plain text
- Expressions (
{$var},{$var :func},{|literal|}) - Markup elements (
{#tag},{/tag},{#tag /}) - Escape sequences (
\\,\{,\},\|)
Pattern Matching with .match
The .match statement selects one of several variant patterns based on the runtime value of one or more selector expressions.
Single Selector
.input {$count :number}
.match $count
0 {{No items.}}
1 {{One item.}}
* {{You have {$count} items.}}Multiple Selectors
.input {$gender :string}
.input {$count :integer}
.match $gender $count
male 1 {{He has one item.}}
female 1 {{She has one item.}}
* 1 {{They have one item.}}
male * {{He has {$count} items.}}
female * {{She has {$count} items.}}
* * {{They have {$count} items.}}Variant Keys
Each variant has one key per selector. Keys can be:
Literal keys: match when the selector value equals the literal (e.g.,
0,1,|male|,female)Catchall
*: matches any value (lowest priority)
Matching Rules
- All keys in a variant must match their corresponding selector values
- Literal keys are matched by string or numeric equality
- Variants are sorted by specificity: fewer
*keys = more specific - The most specific matching variant is selected
- If no variant matches, the result is an error
Markup
MF2 supports markup elements for structured output. Markup nodes are parsed but rendered as empty strings in the formatted output, consistent with the ICU4C reference implementation. The MF2 specification does not mandate a particular string output for markup.
Open and Close Tags
iex> Cldr.Message.format("{{Click {#link}here{/link} to continue.}}", %{})
{:ok, "Click here to continue."}Self-Closing Tags
iex> Cldr.Message.format("{{An image: {#img src=|photo.jpg| /}}}", %{})
{:ok, "An image: "}Markup with Options and Attributes
{#button type=|submit| @translatable}Click me{/button}Markup elements accept the same option (key=value) and attribute (@name) syntax as expressions.
Escape Sequences
Within pattern text (inside {{ }}), the following escape sequences are recognized:
| Sequence | Produces |
|---|---|
\\ | \ |
\{ | { |
\} | } |
Within quoted literals (inside | |):
| Sequence | Produces |
|---|---|
\\ | \ |
| | | |
Whitespace and BiDi
MF2 supports Unicode bidirectional control characters within the syntax in specific positions (between declarations, around expressions). The following BiDi characters are recognized:
- U+061C (Arabic Letter Mark)
- U+200E (Left-to-Right Mark)
- U+200F (Right-to-Left Mark)
- U+2066-2069 (Isolate controls)
The ideographic space (U+3000) is treated as whitespace.
Attributes
Attributes provide metadata annotations on expressions and markup. They do not affect formatting output but can be used by tooling (e.g., translation tools, linters).
{$name :string @translatable}
{$count :number @source=|database|}Complete Examples
All examples below use the en-US locale (the default) and have been validated against the Elixir formatter.
Simple Greeting
iex> Cldr.Message.format("{{Hello, {$name}!}}", %{"name" => "World"})
{:ok, "Hello, World!"}Simple Message (no {{ }} wrapper)
A simple message without the {{ }} wrapper is also valid MF2 but since it cannot be auto-detected (it looks like a V1 message), it requires the :version option:
iex> Cldr.Message.format("Hello, {$name}!", %{"name" => "World"}, version: :v2)
{:ok, "Hello, World!"}Number Formatting
iex> Cldr.Message.format(~S"""
...> .input {$count :number}
...> {{You have {$count} items in your cart.}}
...> """, %{"count" => 1234})
{:ok, "You have 1,234 items in your cart."}Number Options
iex> Cldr.Message.format("{{{$n :number minimumFractionDigits=2}}}", %{"n" => 42})
{:ok, "42.00"}
iex> Cldr.Message.format("{{{$n :number maximumFractionDigits=2}}}", %{"n" => 3.14159})
{:ok, "3.14"}
iex> Cldr.Message.format("{{{$n :number useGrouping=never}}}", %{"n" => 12345})
{:ok, "12345"}Integer Formatting
iex> Cldr.Message.format("{{{$n :integer}}}", %{"n" => 4.7})
{:ok, "4"}Percent Formatting
iex> Cldr.Message.format("{{{$ratio :percent}}}", %{"ratio" => 0.85})
{:ok, "85%"}Date Formatting
iex> Cldr.Message.format("{{{|2006-01-02| :date}}}", %{})
{:ok, "Jan 2, 2006"}
iex> Cldr.Message.format("{{{|2006-01-02| :date length=long}}}", %{})
{:ok, "January 2, 2006"}
iex> Cldr.Message.format("{{{|2006-01-02| :date style=short}}}", %{})
{:ok, "1/2/06"}Time Formatting
iex> Cldr.Message.format("{{{|2006-01-02T15:04:06| :time}}}", %{})
{:ok, "3:04:06\u202FPM"}Datetime Formatting
iex> Cldr.Message.format("{{{|2006-01-02T15:04:06| :datetime}}}", %{})
{:ok, "Jan 2, 2006, 3:04:06\u202FPM"}
iex> Cldr.Message.format(
...> "{{{|2006-01-02T15:04:06| :datetime dateStyle=long timeStyle=short}}}",
...> %{}
...> )
{:ok, "January 2, 2006, 3:04\u202FPM"}Plural Selection
iex> Cldr.Message.format(~S"""
...> .input {$count :number}
...> .match $count
...> 0 {{Your cart is empty.}}
...> 1 {{You have one item in your cart.}}
...> * {{You have {$count} items in your cart.}}
...> """, %{"count" => 3})
{:ok, "You have 3 items in your cart."}Local Variable Binding
iex> Cldr.Message.format(~S"""
...> .input {$first :string}
...> .input {$last :string}
...> .local $greeting = {|Welcome|}
...> {{Dear {$first} {$last}, {$greeting}!}}
...> """, %{"first" => "Jane", "last" => "Doe"})
{:ok, "Dear Jane Doe, Welcome!"}Gender and Plural Selection
iex> Cldr.Message.format(~S"""
...> .input {$gender :string}
...> .input {$count :integer}
...> .match $gender $count
...> male 1 {{He bought one item.}}
...> female 1 {{She bought one item.}}
...> * 1 {{They bought one item.}}
...> male * {{He bought {$count} items.}}
...> female * {{She bought {$count} items.}}
...> * * {{They bought {$count} items.}}
...> """, %{"gender" => "female", "count" => 3})
{:ok, "She bought 3 items."}Specification Compliance
The ex_cldr_messages MF2 implementation targets the Unicode MessageFormat 2.0 specification (part of CLDR Technical Standard #35).
Compliance Summary
| Area | Status |
|---|---|
| Simple messages | Fully supported |
| Complex messages (declarations + quoted pattern) | Fully supported |
.input declarations | Fully supported |
.local declarations | Fully supported |
.match with single and multiple selectors | Fully supported |
Variant matching with literal keys and * catchall | Fully supported |
| Quoted and unquoted literals | Fully supported |
| Number literals (integer, decimal, scientific) | Fully supported |
| Variables with string and atom key lookup | Fully supported |
Function annotations (:functionName) | Fully supported |
Function options (key=value) | Fully supported |
Attributes (@name, @name=value) | Parsed; not used in formatting |
| Markup (open, close, self-closing) | Parsed; rendered as empty strings (per ICU4C) |
| Escape sequences | Fully supported |
| BiDi controls and ideographic space | Fully supported |
Namespaced identifiers (ns:name) | Parsed; not semantically interpreted |
| NFC normalization of output | Not implemented |
Built-in Function Registry
The MF2 specification defines a default function registry. The following table shows the implementation status:
| Function | Spec Status | Implementation |
|---|---|---|
:string | Default | Implemented (pass-through coercion) |
:number | Default | Implemented via Cldr.Number |
:integer | Default | Implemented via Cldr.Number with integer format |
:date | Default | Implemented via Cldr.Date with style/length options (optional dep) |
:time | Default | Implemented via Cldr.Time with style/precision options (optional dep) |
:datetime | Default | Implemented via Cldr.DateTime with dateStyle/timeStyle/dateLength/timePrecision options (optional dep) |
:percent | Extended | Implemented via Cldr.Number with percent format |
:currency | Extended | Implemented via Cldr.Number with currency/currencyDisplay/currencySign options |
:unit | Extended | Implemented via Cldr.Unit with unit/unitDisplay options (optional dep) |
Differences from ICU4C Reference Implementation
The Elixir implementation has been validated against the ICU4C reference implementation (via NIF) using the official MF2 conformance test suite. Across 119 comparable test cases, 100% produce identical output.
Cross-locale testing across en, fr, ja, he, th, ar, and de confirms both implementations produce identical output for :number and :integer formatting, including locale-specific grouping separators and decimal separators.
Function Support
The ICU4C NIF supports :number, :integer, :string, :date, :time, and :datetime. The Elixir implementation additionally supports :percent, :currency, and :unit as extended functions. When the NIF encounters an unsupported function (e.g., :percent, :currency), it produces a fallback string like {$variableName}. When using Gettext runtime interpolation with the NIF backend as the default formatter, messages using :currency, :unit, or :percent will produce fallback strings. Use formatter_backend: :elixir or compile-time interpolation (via .po file translations) for full function support.
Markup Handling
Both the Elixir implementation and ICU4C render markup nodes as empty strings. The MF2 specification does not mandate a particular string output for markup — it is left to the implementation.
:string Coercion
The Elixir implementation coerces non-string values (numbers, booleans, atoms) to their string representation when using the :string function. The ICU4C NIF does not coerce and returns an empty string for non-string values.
Date/Time Default Style
The Elixir implementation defaults to :medium style for :date, :time, and :datetime formatting. The ICU4C NIF defaults to :short style. This means the Elixir implementation produces more detailed output by default (e.g., "Mar 15, 2024" vs "3/15/24" for English dates, "15 mars 2024" vs "15/03/2024" for French dates).
:time Input Types
The Elixir :time function accepts Time, NaiveDateTime, DateTime, Date, and ISO 8601 datetime strings. The ICU4C NIF accepts ISO 8601 datetime strings only.
Error Handling
Cldr.Message.format/3 returns {:ok, string} on success. On failure, it returns {:error, {module, reason}} where the module indicates the error type:
{:error, {Cldr.Message.ParseError, reason}}— the message could not be parsed.{:error, {Cldr.Message.BindError, reason}}— a referenced variable has no binding.{:error, {Cldr.Message.FormatError, reason}}— a value could not be formatted by the requested function (e.g., a string passed to:number, an invalid unit name, or an unknown numbering system).
Cldr.Message.format!/3 raises the corresponding exception on error.
Unbound Variable Fallback
When a variable is referenced but no binding is provided:
ICU4C: produces a fallback string
{$variableName}.Elixir (
Cldr.Message.format/3): returns{:error, {Cldr.Message.BindError, reason}}with details of which variables were unbound.Gettext runtime: when the NIF is the default formatter backend, unbound variables produce fallback strings like ICU4C. When using the Elixir backend, Gettext receives
{:missing_bindings, message, names}and logs a warning.
Supported Number Formatting Options
The following MF2 number formatting options are mapped to their ex_cldr_numbers equivalents:
| MF2 Option | CLDR Mapping | Description |
|---|---|---|
minimumFractionDigits | :fractional_digits | Minimum number of decimal places (pads with zeros) |
maximumFractionDigits | Format pattern (e.g. #,##0.##) | Maximum number of decimal places (truncates/rounds) |
useGrouping=never | format: "##0.#" | Suppresses grouping separators |
useGrouping=min2 | minimum_grouping_digits: 2 | Groups only when 2+ digits in the highest group |
useGrouping=auto | Default locale behaviour | Uses the locale default (same as omitting the option) |
useGrouping=always | Default locale behaviour | Uses the locale default |
numberingSystem | :number_system | Selects a numbering system (e.g. arab, latn, deva). Must be valid for the locale. |
select=plural | Cldr.Number.PluralRule.plural_type/2 with :Cardinal | Default for :number. Matches variant keys by CLDR cardinal plural category. |
select=ordinal | Cldr.Number.PluralRule.plural_type/2 with :Ordinal | Matches variant keys by CLDR ordinal plural category. |
select=exact | Literal equality | Matches variant keys by exact value only — no plural category resolution. |
These options can be combined. For example, minimumFractionDigits=1 maximumFractionDigits=4 useGrouping=never will pad to at least 1 decimal place, truncate at 4, and suppress grouping separators.
The following MF2 number formatting options are not yet implemented:
signDisplaynotation(compact,scientific,engineering)minimumIntegerDigitsminimumSignificantDigits/maximumSignificantDigits
Standard number, integer, and percent formatting without these explicit options works correctly and produces locale-appropriate output.
Date/Time Formatting Options
The :date, :time, and :datetime functions accept ISO 8601 string literals which are automatically parsed into Elixir date/time structs. They also accept Date, Time, NaiveDateTime, and DateTime structs directly via bindings.
| Function | Option | CLDR Mapping | Description |
|---|---|---|---|
:date | style / length | :format | Date format style (short, medium, long, full) |
:time | style | :format | Time format style (short, medium, long, full) |
:time | precision | :format | second → :medium, minute → :short |
:datetime | style | :date_format + :time_format | Sets both date and time style |
:datetime | dateStyle / dateLength | :date_format | Date portion style |
:datetime | timeStyle / timePrecision | :time_format | Time portion style/precision |
The following MF2 date/time formatting options are not yet implemented:
- Field options:
weekday,era,year,month,day,hour,minute,second,fractionalSecondDigits,timeZoneName— these would map to CLDR skeleton atoms via the:formatoption (e.g.,{$dt :datetime year=numeric month=short day=numeric}→format: :yMMMd) hourCycle(h11,h12,h23,h24) — controls 12-hour vs 24-hour clockcalendar— selects a calendar system (e.g.,buddhist,islamic)
Style-based formatting (dateStyle, timeStyle, style, length, precision) works correctly and produces locale-appropriate output.
Unicode NFC Normalization
The Elixir implementation applies NFC normalization to variable names, literal values, and binding keys, matching the MF2 specification requirements.
Unknown / Custom Functions
When a message references a function not known to the implementation:
- ICU4C: produces a fallback string like
{$var :unknownFn}or{:unknownFn} - Elixir: falls back to string coercion of the operand value
Literal / Number Ambiguity
Edge cases involving numeric-looking literals (e.g. 0E1, 1E+2) may be interpreted differently between the two implementations due to parser-level disambiguation. These are uncommon in real-world messages.
Plural Category Selection
The :number and :integer functions support plural category matching when used as selectors in .match expressions. The select option controls the matching behaviour:
select=plural(default for:numberand:integer): Resolves the numeric value to a CLDR cardinal plural category (zero,one,two,few,many,other) usingCldr.Number.PluralRule.plural_type/2. Exact numeric keys (e.g.1,42) are matched first, then plural category keys.select=ordinal: Resolves to CLDR ordinal plural categories (e.g. in English: 1→one, 2→two, 3→few, 4→other).select=exact: Matches by literal equality only — no plural category resolution.
When :integer is used as a selector, the value is truncated to an integer before matching (e.g. 1.2 matches key 1).