●RORKMAX — Rork Max generates pure Swift instead of React Native, enabling true native apps across iPhone, iPad, Watch, TV, Vision Pro, and iMessage●APPLE — Rork's 2026 direction has a clear theme of native empowerment across the Apple ecosystem●EXPO — Standard builds run on React Native and Expo, so you're left with a real project structure and code you can keep working on●FUNDING — Rork recently raised $15M and now sees over 743,000 monthly visits with 85% growth●PRICING — Rork is free to start, with paid plans from $25/month and Rork Max at $200/month●CROSS — Rork builds iOS, Android, and web from a single prompt, finished off with a bit of follow-up tweaking●RORKMAX — Rork Max generates pure Swift instead of React Native, enabling true native apps across iPhone, iPad, Watch, TV, Vision Pro, and iMessage●APPLE — Rork's 2026 direction has a clear theme of native empowerment across the Apple ecosystem●EXPO — Standard builds run on React Native and Expo, so you're left with a real project structure and code you can keep working on●FUNDING — Rork recently raised $15M and now sees over 743,000 monthly visits with 85% growth●PRICING — Rork is free to start, with paid plans from $25/month and Rork Max at $200/month●CROSS — Rork builds iOS, Android, and web from a single prompt, finished off with a bit of follow-up tweaking
When "{n} items" Breaks Across Languages — Designing Quantity Strings with CLDR Plural Categories and Intl.PluralRules
The assumption that one is singular and everything else is plural falls apart in Russian and Arabic. Here is how to hold the CLDR plural categories as a map and render quantities correctly with Intl.PluralRules and i18next, drawn from localizing an indie wallpaper app into sixteen languages.
A few days after shipping a wallpaper app in sixteen languages, I got a short note from a Russian-speaking user: the favorites count "looked off somehow." On my own iPhone, in Japanese and in English, nothing was wrong.
It took me a while to trace it. I had written the count as n === 1 ? "1 item" : n + " items". If you only ever look at English and Japanese, that branch looks permanently correct. But in Russian the word form changes between two and five, and then twenty-one snaps back to the same form as one. The very premise of the ternary simply did not exist in that language.
This article reframes how quantity strings branch per language through the map of CLDR plural categories, then walks through selecting the right form with Intl.PluralRules and i18next. When you send a Rork or Expo app out into the world, this is the quiet trap I want to close before release.
Where "one is singular, everything else is plural" stops holding
The singular-versus-plural split we carry around unconsciously is really just a convenience of one language: English. Look across the world's languages and quantity forms are far more varied.
Japanese has no grammatical plural at all. "1 item" and "3 items" share the same form; you just drop n in. English is special only at one, then flat. If that were the whole world, a ternary would be enough.
The trouble starts beyond that. Russian and Ukrainian pick among three forms based on the trailing digits. Polish is similarly intricate, and Arabic carries six forms: zero, one, two, few, many, and other. A single "everything else is plural" branch cannot render any of those languages correctly.
So quantity bugs surface only in the languages a developer can't read. They never reproduce on your own device, so review never catches them. That is exactly why the per-language rules should live in a standardized map, not in your head.
Hold the CLDR plural categories as a map
That map is the CLDR (Unicode Common Locale Data Repository) plural categories. CLDR defines each language's quantity rules as a mapping onto one of six categories.
Category
Meaning
Representative languages
one
Singular-like
English, German, Spanish (at 1)
other
Default / everything else
Japanese, Chinese, Korean (only this)
few
Small counts
Russian, Polish, Czech
many
Large counts
Russian, Polish, Arabic
two
Dual
Arabic, Slovenian, Welsh
zero
Zero
Arabic, Latvian
The point is that these are not numbers but a language's own partitioning of word forms. Russian classifying 21 as one is counterintuitive, yet you never have to memorize the rules. Let CLDR hold the map; you only supply one string per category name.
In a language like Japanese that has only other, the translator writes a single string. Whether more categories exist is the language's business, and the calling code should absorb that difference behind one identical call.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why a ternary plural breaks in Russian, Arabic, and Polish, explained through CLDR categories
✦A reusable helper that selects the right form with Intl.PluralRules, plus a Hermes fallback
✦How to keep cardinals and ordinals apart, and how to test the languages that actually break
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Happily, looking up this map is built into the standard library. Give Intl.PluralRules a locale and a number, and it returns the matching category name.
// A thin wrapper that resolves a number to its CLDR category.// Reuse one instance per locale to keep the cost down.const rulesCache = new Map<string, Intl.PluralRules>();function pluralCategory(locale: string, n: number): Intl.PluralCategory { let rules = rulesCache.get(locale); if (!rules) { rules = new Intl.PluralRules(locale); // defaults to cardinal rulesCache.set(locale, rules); } return rules.select(n);}// ExamplespluralCategory("en", 1); // -> "one"pluralCategory("en", 5); // -> "other"pluralCategory("ru", 2); // -> "few"pluralCategory("ru", 5); // -> "many"pluralCategory("ru", 21); // -> "one"pluralCategory("ja", 3); // -> "other"
select() always returns one of the six category names. From there you look up the string for that category, and you never write the per-language branch yourself. Notice Russian's 21 landing on one — a result the ternary could never reach, quietly corrected by the API.
One caveat: Hermes, the JavaScript engine behind React Native, only provides Intl.PluralRules in builds with Intl enabled. On a configuration without it, you hit a runtime exception, so wrap the lookup in a layer that checks for existence and falls back.
// Fall back to "other" when Intl.PluralRules is unavailable.// At minimum, guarantee the sentence doesn't break in the majority language.function safeCategory(locale: string, n: number): Intl.PluralCategory { try { if (typeof Intl?.PluralRules === "function") { return pluralCategory(locale, n); } } catch { // Swallow a runtime failure and fall to the default. } return n === 1 ? "one" : "other";}
The fallback is a coarse, English-shaped rule, but the priority is that the app doesn't crash even on an Intl-disabled build. Where possible, enable Hermes Intl or load the @formatjs/intl-pluralrules polyfill to recover the true categories — that is the design you actually want.
Keep i18next plural keys as data, not code
If you use i18next for translations, this map maps straight onto your key naming. Internally i18next uses the same categories as Intl.PluralRules, resolving keys through suffixes like _one, _few, _many, and _other.
// ja/translation.json — other alone is enough{ "favorites_other": "お気に入り {{count}} 件"}
// ru/translation.json — provide few / many / other{ "favorites_one": "{{count}} избранное", "favorites_few": "{{count}} избранных", "favorites_many": "{{count}} избранных", "favorites_other": "{{count}} избранного"}
// The call site stays language-agnostict("favorites", { count: favorites.length });
The caller just passes count, and i18next picks the correctly suffixed key for the current locale. What you want to avoid is choosing favorites_one versus favorites_other yourself with count === 1 and baking it into the key. That throws away the map the library already holds and drags you back to the English binary. Decide once that the quantity branch lives in the data — the translation keys — not in the code.
Detecting missing keys and designing the fallback chain is a separate topic; I collected that in notes on stopping missing translations in CI. This article stays focused on the layer that selects the correct form.
Cardinals and ordinals follow different rules
There is one more branch that is easy to miss. A count like "3 items" (cardinal) and a rank like "3rd" (ordinal) are governed by different rules, even within the same language. English is the clearest case: cardinals split into one/other, but ordinals carry four forms — 1st, 2nd, 3rd, 4th.
Intl.PluralRules handles the difference through the type option.
Reuse the cardinal rule for a leaderboard or a "your Nth piece" label and English will emit something like "3th." Separating count from rank as distinct form designs from the start means the same map keeps working when you add rankings later.
Use the same map on the native side
For the native Swift that Rork Max generates, or when you use Xcode's String Catalog, the source of truth is the same CLDR. String Catalog lets a string vary by plural, and internally that maps onto the same categories (one/few/many/other …) as the older .stringsdict.
What matters for cross-language consistency is not holding separate branching logic in JavaScript and in native code. Keep both on the same two-step shape — look up a category from a number, then assign a string to that category — and translation additions and checks run through a single procedure. Even when the implementation languages differ, keep the map in your head a single sheet. That is the design decision.
Test the languages that break
The nasty part of this class of bug is that it never reproduces in a language the developer can read. So the effective move is to aim tests at languages you can't read.
First, use a pseudo-locale to surface layout breakage when strings expand and wrap. Then name the high-category languages explicitly and pin them into snapshots.
// Pin languages with many categories as representatives of what breaksconst RISKY = ["ru", "ar", "pl"] as const;const SAMPLES = [0, 1, 2, 3, 5, 11, 21, 100];for (const locale of RISKY) { for (const n of SAMPLES) { const cat = new Intl.PluralRules(locale).select(n); // Match the expected category against the presence of the translation key expect(translationHasKey(`favorites_${cat}`, locale)).toBe(true); }}
The goal here is not the correctness of the wording itself but a mechanical check that no category a language needs is missing. Forgetting only the many key in Russian is invisible to the eye. Let CI watch the correspondence between categories and keys, and you get the same reassurance every time you add a language.
Closing thoughts
A quantity string is a plain single line inside an app. Yet it carries what feels natural to someone who speaks that language. Let go of the familiar "one is singular, everything else is plural" for a moment and hand the judgment to the map of CLDR categories. That alone lets you deliver a quietly correct display even to readers in languages you can't read.
I missed this trap in my first wallpaper app and only learned it from a post-release report. If it spares someone the same detour, I'll be glad. Thank you for reading.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.