IntlPull
Technical
16 min read

ICU Message Format 2026: Plurals, Select Syntax & Examples

Master ICU MessageFormat syntax with plural rules, select format, nested messages, number/date formatting, examples, and validation tips.

IntlPull Team
IntlPull Team
Feb 12, 2026
On this page
Summary

Master ICU MessageFormat syntax with plural rules, select format, nested messages, number/date formatting, examples, and validation tips.

ICU MessageFormat is a standardized syntax for creating internationalized messages that adapt dynamically to linguistic rules like pluralization, gender agreement, and grammatical cases, solving problems that simple string interpolation cannot address while maintaining readability for both developers and translators. Developed by the International Components for Unicode (ICU) project and based on Unicode CLDR (Common Locale Data Repository) linguistic data, ICU MessageFormat provides a declarative way to express complex translation logic that would otherwise require extensive programming with conditional statements scattered throughout application code. The syntax handles diverse language requirements—from English's simple singular/plural distinction to Arabic's six plural forms, from gender-neutral languages like Turkish to heavily gendered Romance languages, from straightforward substitution to complex nested conditions—all within translator-editable string resources rather than hardcoded business logic. Modern i18n libraries including react-intl, FormatJS, vue-i18n, and i18next support ICU MessageFormat as their primary or optional message syntax, making it the de facto standard for sophisticated internationalization in 2026.

The Problem ICU MessageFormat Solves

Before exploring syntax details, understanding why ICU MessageFormat exists clarifies when and how to use it effectively.

The Limitations of Simple String Interpolation

Basic variable substitution fails for grammatically complex messages:

English (appears to work):

"You have {count} messages"
count = 1 → "You have 1 messages" ❌
count = 5 → "You have 5 messages" ✅

The naive fix uses conditionals in code:

JavaScript
1function getMessage(count) {
2  if (count === 1) {
3    return `You have ${count} message`;
4  } else {
5    return `You have ${count} messages`;
6  }
7}

This seems fine until you translate to Polish, which has four plural forms:

  • 1: "Masz 1 wiadomość"
  • 2-4: "Masz 2 wiadomości"
  • 5-21: "Masz 5 wiadomości"
  • 22-24: "Masz 22 wiadomości"
  • (Pattern repeats)

Or Russian with three forms, or Arabic with six. The conditional logic becomes unmaintainable and must be reimplemented for every language.

Gender Agreement Challenges

Romance languages require adjectives and past participles to agree with the subject's gender:

French:

  • "{name} est connecté" (masculine)
  • "{name} est connectée" (feminine)

Without ICU, you'd need:

JavaScript
1if (user.gender === 'male') {
2  return `${user.name} est connecté`;
3} else {
4  return `${user.name} est connectée`;
5}

This couples translation logic with code and makes translator workflows cumbersome.

Combined Conditions

Real-world messages often combine multiple variables with interdependent grammatical rules:

"Sarah added 3 photos to her album" "John added 1 photo to his album" "They added 5 photos to their album"

You need gender agreement (his/her/their) AND pluralization (photo/photos) simultaneously. ICU handles this elegantly in a single translator-editable string.

Basic ICU MessageFormat Syntax

ICU messages consist of plain text with embedded argument placeholders.

Simple Arguments

The most basic form substitutes variables:

Hello, {name}!

Usage:

JavaScript
1import { IntlProvider, FormattedMessage } from 'react-intl';
2
3<FormattedMessage
4  id="greeting"
5  defaultMessage="Hello, {name}!"
6  values={{ name: 'Sarah' }}
7/>
8// Output: "Hello, Sarah!"

Multiple arguments:

{userName} sent {count} messages to {recipientName}
JavaScript
1<FormattedMessage
2  id="message.sent"
3  defaultMessage="{userName} sent {count} messages to {recipientName}"
4  values={{
5    userName: 'Alice',
6    count: 5,
7    recipientName: 'Bob'
8  }}
9/>
10// Output: "Alice sent 5 messages to Bob"

Argument Types

Arguments can have types that control formatting:

{variable, type, format}

Common types:

  • number: Numeric formatting
  • date: Date formatting
  • time: Time formatting
  • plural: Plural selection
  • select: Arbitrary selection
  • selectordinal: Ordinal selection (1st, 2nd, 3rd)

Plural Rules: The Core ICU Feature

Pluralization is ICU's most powerful capability, encoding CLDR linguistic rules for 200+ languages.

Basic Plural Syntax

{count, plural,
  =0 {No items}
  one {One item}
  other {# items}
}

Breakdown:

  • count: The variable name
  • plural: The type
  • =0, one, other: Plural categories
  • {No items}: Message for that category
  • #: Placeholder for the numeric value

Usage:

JavaScript
1<FormattedMessage
2  id="cart.items"
3  defaultMessage="{count, plural, =0 {No items} one {One item} other {# items}}"
4  values={{ count: 0 }}
5/>
6// Output: "No items"
7
8<FormattedMessage
9  id="cart.items"
10  defaultMessage="{count, plural, =0 {No items} one {One item} other {# items}}"
11  values={{ count: 1 }}
12/>
13// Output: "One item"
14
15<FormattedMessage
16  id="cart.items"
17  defaultMessage="{count, plural, =0 {No items} one {One item} other {# items}}"
18  values={{ count: 5 }}
19/>
20// Output: "5 items"

CLDR Plural Categories

Different languages use different plural categories:

LanguageCategoriesRules
Englishone, otherone: n=1
Frenchone, otherone: n=0 or n=1
Russianone, few, many, otherComplex rules based on last digits
Polishone, few, many, otherEven more complex rules
Arabiczero, one, two, few, many, otherSix distinct forms
ChineseotherNo pluralization
Welshzero, one, two, few, many, otherSix forms with unique rules

You don't need to memorize these rules—CLDR data is built into ICU libraries. Just provide translations for the categories relevant to each language.

Exact Matches with =n

Use =n for exact numeric values that should override category rules:

{count, plural,
  =0 {No messages}
  =1 {One message}
  other {# messages}
}

This is useful for special cases like "No items" instead of "0 items", or promotional messages like:

{days, plural,
  =7 {One week free trial!}
  =14 {Two weeks free trial!}
  =30 {One month free trial!}
  other {# days free trial}
}

Complete Language Example: Russian

Russian requires three plural forms. Here's a real translation:

English:

{count, plural,
  one {# item}
  other {# items}
}

Russian:

{count, plural,
  one {# предмет}
  few {# предмета}
  many {# предметов}
}

CLDR automatically applies correct rules:

  • 1, 21, 31: "1 предмет" (one)
  • 2, 3, 4, 22, 23, 24: "2 предмета" (few)
  • 5-20, 25-30: "5 предметов" (many)

The # Symbol

# is replaced with the numeric value. It respects locale formatting:

JavaScript
1// English locale
2{count: 1000, plural, other {# items}}"1,000 items"
3
4// German locale
5{count: 1000, plural, other {# items}}"1.000 items"
6
7// French locale
8{count: 1000, plural, other {# items}}"1 000 items"

To display the number without formatting, repeat the variable name:

{count, plural, other {{count} items}}

Select Format: Gender and Arbitrary Selection

select handles any categorical variable, most commonly gender.

Basic Select Syntax

{gender, select,
  male {He is online}
  female {She is online}
  other {They are online}
}

Usage:

JavaScript
1<FormattedMessage
2  id="user.status"
3  defaultMessage="{gender, select, male {He is online} female {She is online} other {They are online}}"
4  values={{ gender: 'female' }}
5/>
6// Output: "She is online"

The other Category is Required

Unlike plural, which can infer categories, select requires an explicit other fallback:

{role, select,
  admin {Administrator dashboard}
  editor {Editor panel}
  viewer {Viewer mode}
  other {User dashboard}
}

If role is "guest" or any value not explicitly handled, it uses "User dashboard".

Real-World Example: Notification Messages

{senderGender, select,
  male {{senderName} commented on his post}
  female {{senderName} commented on her post}
  other {{senderName} commented on their post}
}

This pattern is common in social apps, activity feeds, and notifications.

Beyond Gender: Any Categorical Variable

Select works for any categorical data:

{accountType, select,
  free {Upgrade to Pro for more features}
  pro {You have access to all features}
  enterprise {Contact your account manager}
  other {Unknown account type}
}

SelectOrdinal: 1st, 2nd, 3rd...

selectordinal formats ordinal numbers (ranking, positions) according to language rules.

Basic Selectordinal Syntax

{rank, selectordinal,
  one {#st place}
  two {#nd place}
  few {#rd place}
  other {#th place}
}

Usage:

JavaScript
1<FormattedMessage
2  id="contest.rank"
3  defaultMessage="{rank, selectordinal, one {#st place} two {#nd place} few {#rd place} other {#th place}}"
4  values={{ rank: 1 }}
5/>
6// Output: "1st place"
7
8<FormattedMessage
9  id="contest.rank"
10  defaultMessage="{rank, selectordinal, one {#st place} two {#nd place} few {#rd place} other {#th place}}"
11  values={{ rank: 3 }}
12/>
13// Output: "3rd place"

Language-Specific Ordinal Rules

Different languages have different ordinal patterns:

English:

  • one: 1st, 21st, 31st (ends in 1, except 11)
  • two: 2nd, 22nd, 32nd (ends in 2, except 12)
  • few: 3rd, 23rd, 33rd (ends in 3, except 13)
  • other: 4th-20th, 24th-30th

French (simpler):

  • one: 1er (premier)
  • other: 2e, 3e, 4e... (all use 'e')

Chinese (even simpler):

  • other: 第1, 第2, 第3... (all use same prefix)

Nested Messages: Combining Plural and Select

Real-world messages often require multiple conditions simultaneously.

Plural + Select Combination

{gender, select,
  male {
    {count, plural,
      one {He uploaded # photo}
      other {He uploaded # photos}
    }
  }
  female {
    {count, plural,
      one {She uploaded # photo}
      other {She uploaded # photos}
    }
  }
  other {
    {count, plural,
      one {They uploaded # photo}
      other {They uploaded # photos}
    }
  }
}

Usage:

JavaScript
1<FormattedMessage
2  id="photo.upload"
3  defaultMessage="{gender, select, male {{count, plural, one {He uploaded # photo} other {He uploaded # photos}}} female {{count, plural, one {She uploaded # photo} other {She uploaded # photos}}} other {{count, plural, one {They uploaded # photo} other {They uploaded # photos}}}}"
4  values={{ gender: 'female', count: 3 }}
5/>
6// Output: "She uploaded 3 photos"

Complex Example: Shopping Cart

{itemCount, plural,
  =0 {Your cart is empty}
  other {
    You have # {itemCount, plural, one {item} other {items}}.
    {subtotal, number, currency}
  }
}

Breakdown:

  1. If itemCount is 0: "Your cart is empty"
  2. Otherwise: "You have # item(s). $X.XX"
  3. Inner plural handles item/items
  4. Number formatting for currency

Number Formatting

ICU provides rich number formatting options using the Intl.NumberFormat spec.

Basic Number Format

{price, number}

This applies locale-specific number formatting:

JavaScript
1// US locale
2{price: 1234.56, number}"1,234.56"
3
4// German locale
5{price: 1234.56, number}"1.234,56"
6
7// French locale
8{price: 1234.56, number}"1 234,56"

Currency Format

{price, number, currency}

The currency code is passed as a separate variable:

JavaScript
1<FormattedMessage
2  id="product.price"
3  defaultMessage="Price: {price, number, currency}"
4  values={{ price: 29.99, currency: 'USD' }}
5/>
6// Output (US locale): "Price: $29.99"
7// Output (EU locale): "Price: 29,99 $US"

Percent Format

{rate, number, percent}
JavaScript
1<FormattedMessage
2  id="discount"
3  defaultMessage="Save {discount, number, percent}!"
4  values={{ discount: 0.25 }}
5/>
6// Output: "Save 25%!"

Custom Number Formats

Define custom formats in your intl configuration:

JavaScript
1const intl = createIntl({
2  locale: 'en-US',
3  messages,
4  formats: {
5    number: {
6      USD: {
7        style: 'currency',
8        currency: 'USD',
9        minimumFractionDigits: 2
10      },
11      compact: {
12        notation: 'compact',
13        compactDisplay: 'short'
14      }
15    }
16  }
17});

Usage:

{price, number, USD} → "$29.99"
{followers, number, compact} → "1.2K"

Date and Time Formatting

Basic Date Format

{date, date, short}
{date, date, medium}
{date, date, long}
{date, date, full}
JavaScript
1<FormattedMessage
2  id="order.date"
3  defaultMessage="Ordered on {orderDate, date, long}"
4  values={{ orderDate: new Date('2026-02-12') }}
5/>
6// Output (US): "Ordered on February 12, 2026"
7// Output (DE): "Ordered on 12. Februar 2026"

Time Format

{time, time, short}
{time, time, medium}
JavaScript
1<FormattedMessage
2  id="delivery.time"
3  defaultMessage="Delivered at {deliveryTime, time, short}"
4  values={{ deliveryTime: new Date('2026-02-12T14:30:00') }}
5/>
6// Output (US): "Delivered at 2:30 PM"
7// Output (24h locale): "Delivered at 14:30"

Custom Date/Time Formats

JavaScript
1const intl = createIntl({
2  locale: 'en-US',
3  formats: {
4    date: {
5      monthYear: {
6        year: 'numeric',
7        month: 'long'
8      }
9    },
10    time: {
11      hourMinute: {
12        hour: 'numeric',
13        minute: '2-digit'
14      }
15    }
16  }
17});

Usage:

{date, date, monthYear} → "February 2026"
{time, time, hourMinute} → "2:30"

Escaping Special Characters

ICU reserves certain characters ({, }, #) that require escaping in literal text.

Escaping Curly Braces

To display literal { or }:

Use '{' and '}' like this: '{example}'

Output: "Use { and } like this: {example}"

For single quotes within messages:

It's a nice day → No escaping needed

'It''s a nice day' → Output: "It's a nice day" (doubled single quotes)

Escaping the # Symbol

Inside plural/selectordinal, # is special. To show literal #:

{count, plural,
  one {Item '#'1}
  other {Items '#'{count}}
}

Better approach: Avoid # in literals, use different symbols.

Common Mistakes and Debugging

Mistake 1: Missing other Category

{count, plural,
  one {One item}
  // ❌ Missing 'other' category
}

Error: "Missing required 'other' case"

Fix: Always include other:

{count, plural,
  one {One item}
  other {# items}
}

Mistake 2: Using Plural Categories for Wrong Language

// English translation (correct)
{count, plural, one {# item} other {# items}}

// Polish translation (wrong - missing categories)
{count, plural, one {# przedmiot} other {# przedmioty}}

Polish needs few and many categories:

{count, plural,
  one {# przedmiot}
  few {# przedmiot}
  many {# przedmiotów}
  other {# przedmiotów}
}

Mistake 3: Mismatched Argument Names

JavaScript
1// Message
2"{userName} sent a message"
3
4// Usage (typo in variable name)
5<FormattedMessage
6  id="message"
7  values={{ username: 'Alice' }}  // ❌ Should be userName
8/>
9// Output: " sent a message" (userName is undefined)

Fix: Match variable names exactly, or use TypeScript for type safety.

Mistake 4: Over-Nesting

{a, select,
  x {{b, select,
    y {{c, plural,
      one {{d, select, ...}}
      other {...}
    }}
    other {...}
  }}
  other {...}
}

This is technically valid but unmaintainable. Refactor into multiple messages or simplify logic.

Library Support and Integration

react-intl (FormatJS)

Terminal
npm install react-intl
TSX
1import { IntlProvider, FormattedMessage } from 'react-intl';
2
3const messages = {
4  en: {
5    'cart.items': '{count, plural, =0 {No items} one {One item} other {# items}}'
6  }
7};
8
9function App() {
10  return (
11    <IntlProvider locale="en" messages={messages.en}>
12      <Cart />
13    </IntlProvider>
14  );
15}
16
17function Cart() {
18  const itemCount = 5;
19
20  return (
21    <div>
22      <FormattedMessage id="cart.items" values={{ count: itemCount }} />
23    </div>
24  );
25}

i18next

i18next requires ICU plugin:

Terminal
npm install i18next i18next-icu
JavaScript
1import i18next from 'i18next';
2import ICU from 'i18next-icu';
3
4i18next
5  .use(ICU)
6  .init({
7    resources: {
8      en: {
9        translation: {
10          'cart.items': '{count, plural, =0 {No items} one {One item} other {# items}}'
11        }
12      }
13    }
14  });
15
16i18next.t('cart.items', { count: 5 }); // "5 items"

vue-i18n

Vue 3 + vue-i18n v9+ supports ICU via @intlify/message-compiler:

Terminal
npm install vue-i18n@next
JavaScript
1import { createI18n } from 'vue-i18n';
2
3const i18n = createI18n({
4  locale: 'en',
5  messages: {
6    en: {
7      cart: {
8        items: '{count, plural, =0 {No items} one {One item} other {# items}}'
9      }
10    }
11  }
12});
13
14// In component
15{{ $t('cart.items', { count: 5 }) }}

Angular (Internationalization with @angular/localize)

Angular doesn't natively support ICU in the same way, but provides similar functionality via i18n attributes:

HTML
1<span i18n="@@cart.items">
2  {count, plural,
3    =0 {No items}
4    one {One item}
5    other {{{count}} items}
6  }
7</span>

IntlPull's ICU Editor

IntlPull provides a visual ICU MessageFormat editor that helps translators work with complex ICU syntax without breaking message structure. Features include:

  • Syntax highlighting and validation
  • Visual preview with sample data
  • Category suggestions based on target language CLDR rules
  • Automatic format preservation during translation
  • Real-time error detection for missing categories

Try it at intlpull.com/tools/icu-editor.

Advanced Patterns and Best Practices

Pattern 1: Extracting Common Fragments

For repeated phrases, use variables:

JavaScript
1const fragments = {
2  userName: <b>{userName}</b>
3};
4
5<FormattedMessage
6  id="notification"
7  defaultMessage="{userName} commented on your post"
8  values={fragments}
9/>

Pattern 2: Parameterizing Units

Don't hardcode units in translations:

// ❌ Bad
{count, plural, one {# kilometer} other {# kilometers}}

// ✅ Good
{count, plural, one {# {unit}} other {# {unit}}}

Pass unit as variable for flexibility (km vs. mi).

Pattern 3: Accessibility Considerations

Provide alternative text for screen readers:

TSX
1<FormattedMessage
2  id="button.delete"
3  defaultMessage="Delete {count, plural, one {# item} other {# items}}"
4  values={{ count }}
5>
6  {(text) => (
7    <button aria-label={text}>
8      <TrashIcon />
9      <span>{text}</span>
10    </button>
11  )}
12</FormattedMessage>

Pattern 4: Performance Optimization

Memoize complex ICU messages:

TSX
1import { useMemo } from 'react';
2import { useIntl } from 'react-intl';
3
4function ExpensiveComponent({ items }) {
5  const intl = useIntl();
6
7  const message = useMemo(
8    () => intl.formatMessage(
9      { id: 'items.summary' },
10      { count: items.length }
11    ),
12    [intl, items.length]
13  );
14
15  return <div>{message}</div>;
16}

Testing ICU Messages

Unit Testing

TypeScript
1import { createIntl, createIntlCache } from 'react-intl';
2
3describe('ICU Messages', () => {
4  const cache = createIntlCache();
5  const intl = createIntl({
6    locale: 'en',
7    messages: {
8      'cart.items': '{count, plural, =0 {No items} one {One item} other {# items}}'
9    }
10  }, cache);
11
12  it('handles zero items', () => {
13    expect(intl.formatMessage({ id: 'cart.items' }, { count: 0 }))
14      .toBe('No items');
15  });
16
17  it('handles one item', () => {
18    expect(intl.formatMessage({ id: 'cart.items' }, { count: 1 }))
19      .toBe('One item');
20  });
21
22  it('handles multiple items', () => {
23    expect(intl.formatMessage({ id: 'cart.items' }, { count: 5 }))
24      .toBe('5 items');
25  });
26});

Snapshot Testing

TypeScript
1import { render } from '@testing-library/react';
2import { IntlProvider } from 'react-intl';
3
4it('renders cart summary correctly', () => {
5  const { container } = render(
6    <IntlProvider locale="en" messages={messages}>
7      <CartSummary items={[...]} />
8    </IntlProvider>
9  );
10
11  expect(container).toMatchSnapshot();
12});

Cross-Language Testing

Test that all languages have valid ICU syntax:

TypeScript
1import IntlMessageFormat from 'intl-messageformat';
2
3describe('Translation Validity', () => {
4  const languages = ['en', 'es', 'de', 'fr'];
5
6  languages.forEach(lang => {
7    it(`all ${lang} messages compile`, () => {
8      Object.entries(messages[lang]).forEach(([key, message]) => {
9        expect(() => {
10          new IntlMessageFormat(message, lang);
11        }).not.toThrow();
12      });
13    });
14  });
15});

FAQ

When should I use ICU MessageFormat vs. simple interpolation?

Use simple interpolation for basic variable substitution without grammatical changes. Use ICU for any message involving plurals, gender agreement, or conditional text based on variables. If you're unsure, start with ICU—it's more flexible and prevents future refactoring.

Do all i18n libraries support ICU MessageFormat?

Most modern libraries support ICU, but some require plugins. react-intl and FormatJS support it natively. i18next requires the i18next-icu plugin. vue-i18n v9+ supports it. Check your library's documentation for ICU support details.

How do I validate ICU syntax before deployment?

Use linters like eslint-plugin-formatjs that parse and validate ICU messages during development. IntlPull's platform includes automatic ICU validation that flags syntax errors, missing plural categories, and format mismatches before translations go live.

Can I use ICU MessageFormat for server-side rendering?

Yes. Libraries like @formatjs/intl work in Node.js environments. Ensure you polyfill Intl APIs for older Node versions (pre-13), or use full-icu build. Most modern deployments (Node 14+) include full Intl support by default.

What's the performance impact of ICU MessageFormat?

ICU message compilation happens once and is cached, so runtime overhead is minimal. Complex nested messages add microseconds of processing time—negligible compared to network requests and rendering. For extremely high-traffic applications, pre-compile messages during build time using babel-plugin-formatjs.

How do I handle ICU messages in translation workflows?

Modern translation management systems (IntlPull, Lokalise, Phrase) preserve ICU syntax during translation and provide visual editors for translators. Educate translators about placeholder preservation and provide context for variables. Use ICU-aware CAT tools that show variables as non-translatable segments.

Can I mix ICU and non-ICU messages in the same app?

Yes, but it's not recommended. Mixing syntaxes creates confusion for developers and translators. Choose one approach and stick to it. If migrating from simple interpolation to ICU, do it gradually file-by-file, but maintain consistency within each file.

Tags
icu
message-format
plurals
select
syntax
i18n
formatting
cldr
IntlPull Team
IntlPull Team
Engineering

Building tools to help teams ship products globally. Follow us for more insights on localization and i18n.