MessageFormat 2 Syntax and Usage

Copy Markdown View Source

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.5e3

Expressions

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 attribute

General Form

{ [operand] [:function [options...]] [@attribute...] }

Where:

  • operand is a variable ($name), quoted literal (|text|), or number literal (42)
  • function is :functionName optionally followed by space-separated key=value options
  • attributes are @name or @name=value metadata 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}
OptionValuesDescription
minimumFractionDigitsinteger (e.g., 2)Minimum decimal places — pads with trailing zeros.
maximumFractionDigitsinteger (e.g., 4)Maximum decimal places — rounds or truncates beyond this.
useGroupingauto (default), always, min2, neverControls grouping separators (e.g., commas). never suppresses them. min2 groups only when 2+ digits in the highest group.
numberingSystemlatn (default), arab, deva, etc.Selects a numbering system. Must be valid for the locale.
selectplural (default), ordinal, exactControls .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}
OptionValuesDescription
currencyISO 4217 code (e.g., USD, EUR)The currency to format with (required)
currencyDisplaysymbol (default), narrowSymbol, codeHow to display the currency identifier
currencySignstandard (default), accountingaccounting 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 currency is taken from the struct's :currency field unless an explicit currency option is provided in the message.

  • The numeric amount is taken from the struct's :amount field.

  • Any :format_options stored 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}
OptionValuesDescription
unitCLDR unit identifier (e.g., kilometer, kilogram)The unit to format with (required)
unitDisplaylong, short, narrowHow 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 unit is taken from the struct's :unit field unless an explicit unit option is provided in the message.

  • The numeric value is taken from the struct's :value field.

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}
OptionValuesDescription
styleshort, medium, long, fullDate format style (default: medium)
lengthshort, medium, long, fullAlias 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}
OptionValuesDescription
styleshort, medium, long, fullTime format style (default: medium)
precisionsecond, minuteTime 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}
OptionValuesDescription
styleshort, medium, long, fullSets both date and time format style (default: medium)
dateStyleshort, medium, long, fullDate portion format style
dateLengthshort, medium, long, fullAlias for dateStyle
timeStyleshort, medium, long, fullTime portion format style
timePrecisionsecond, minuteTime 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

  1. All keys in a variant must match their corresponding selector values
  2. Literal keys are matched by string or numeric equality
  3. Variants are sorted by specificity: fewer * keys = more specific
  4. The most specific matching variant is selected
  5. 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:

SequenceProduces
\\\
\{{
\}}

Within quoted literals (inside | |):

SequenceProduces
\\\
||

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

AreaStatus
Simple messagesFully supported
Complex messages (declarations + quoted pattern)Fully supported
.input declarationsFully supported
.local declarationsFully supported
.match with single and multiple selectorsFully supported
Variant matching with literal keys and * catchallFully supported
Quoted and unquoted literalsFully supported
Number literals (integer, decimal, scientific)Fully supported
Variables with string and atom key lookupFully 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 sequencesFully supported
BiDi controls and ideographic spaceFully supported
Namespaced identifiers (ns:name)Parsed; not semantically interpreted
NFC normalization of outputNot implemented

Built-in Function Registry

The MF2 specification defines a default function registry. The following table shows the implementation status:

FunctionSpec StatusImplementation
:stringDefaultImplemented (pass-through coercion)
:numberDefaultImplemented via Cldr.Number
:integerDefaultImplemented via Cldr.Number with integer format
:dateDefaultImplemented via Cldr.Date with style/length options (optional dep)
:timeDefaultImplemented via Cldr.Time with style/precision options (optional dep)
:datetimeDefaultImplemented via Cldr.DateTime with dateStyle/timeStyle/dateLength/timePrecision options (optional dep)
:percentExtendedImplemented via Cldr.Number with percent format
:currencyExtendedImplemented via Cldr.Number with currency/currencyDisplay/currencySign options
:unitExtendedImplemented 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 OptionCLDR MappingDescription
minimumFractionDigits:fractional_digitsMinimum number of decimal places (pads with zeros)
maximumFractionDigitsFormat pattern (e.g. #,##0.##)Maximum number of decimal places (truncates/rounds)
useGrouping=neverformat: "##0.#"Suppresses grouping separators
useGrouping=min2minimum_grouping_digits: 2Groups only when 2+ digits in the highest group
useGrouping=autoDefault locale behaviourUses the locale default (same as omitting the option)
useGrouping=alwaysDefault locale behaviourUses the locale default
numberingSystem:number_systemSelects a numbering system (e.g. arab, latn, deva). Must be valid for the locale.
select=pluralCldr.Number.PluralRule.plural_type/2 with :CardinalDefault for :number. Matches variant keys by CLDR cardinal plural category.
select=ordinalCldr.Number.PluralRule.plural_type/2 with :OrdinalMatches variant keys by CLDR ordinal plural category.
select=exactLiteral equalityMatches 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:

  • signDisplay
  • notation (compact, scientific, engineering)
  • minimumIntegerDigits
  • minimumSignificantDigits / 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.

FunctionOptionCLDR MappingDescription
:datestyle / length:formatDate format style (short, medium, long, full)
:timestyle:formatTime format style (short, medium, long, full)
:timeprecision:formatsecond:medium, minute:short
:datetimestyle:date_format + :time_formatSets both date and time style
:datetimedateStyle / dateLength:date_formatDate portion style
:datetimetimeStyle / timePrecision:time_formatTime 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 :format option (e.g., {$dt :datetime year=numeric month=short day=numeric}format: :yMMMd)
  • hourCycle (h11, h12, h23, h24) — controls 12-hour vs 24-hour clock
  • calendar — 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 :number and :integer): Resolves the numeric value to a CLDR cardinal plural category (zero, one, two, few, many, other) using Cldr.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).