Skip to main content
Cross-Platform Localization Engineering

When Your Pluralization Code Meets 12 Grammatical Genders

You're localizing a fitness app into Zulu. English? Simple: '1 workout' / '2 workouts'. Russian? Already messy: '1 тренировка', '2 тренировки', '5 тренировок'. But Zulu has 17 noun classes—each with its own plural prefix. Suddenly your lovely {count, plural, one {workout} other {workouts}} pattern is a joke. You need a pluralization engine that doesn't just handle 'few' and 'many', but respects grammatical genders that aren't even called genders. This article is for engineers who've hit that wall. We're not inventing a new standard. We're fusing ICU4J, CLDR data, and a custom resolver into something that works across Swift, Kotlin, and JS. If your app targets Swahili (6 noun classes), Tamil (two numbers but three pluralities), or Polish (five genders), read on. The rest of us can stop pretending 'one' and 'other' cover the world.

You're localizing a fitness app into Zulu. English? Simple: '1 workout' / '2 workouts'. Russian? Already messy: '1 тренировка', '2 тренировки', '5 тренировок'. But Zulu has 17 noun classes—each with its own plural prefix. Suddenly your lovely {count, plural, one {workout} other {workouts}} pattern is a joke. You need a pluralization engine that doesn't just handle 'few' and 'many', but respects grammatical genders that aren't even called genders.

This article is for engineers who've hit that wall. We're not inventing a new standard. We're fusing ICU4J, CLDR data, and a custom resolver into something that works across Swift, Kotlin, and JS. If your app targets Swahili (6 noun classes), Tamil (two numbers but three pluralities), or Polish (five genders), read on. The rest of us can stop pretending 'one' and 'other' cover the world.

Who Needs a Gender-Aware Pluralization Engine

The gap between ICU MessageFormat and real-world noun classes

Standard pluralization works fine for English—one cat, two cats, done. But throw your app at a language where two exists but three triggers a different form, and ICU MessageFormat's 'one/other' binary starts leaking. That's precisely what happens when your code meets Swahili's noun-class system or Russian's animate-versus-inanimate gender split. The catch is that most cross-platform localization libraries default to CLDR plural rules, which treat gender as a formatting afterthought—a CSS class painted over structural wreckage. I have seen teams ship a calendar widget that counted women correctly but broke on 'boys' because the gender-agreement logic assumed masculine was the default. The seam blows out where noun class and grammatical number intersect: you can't just pluralize a noun and hope the adjective follows.

Concrete examples: Swahili, Russian, Zulu failures

Take Swahili. Its noun classes don't map to 'masculine' or 'feminine'—there are sixteen of them, each demanding distinct prefixes on verbs, adjectives, and even the particle that marks possession. A simple string like '3 unread messages' needs the prefix tatu for the noun class of 'messages' (class 4, if you're counting), not the numeral system you'd use for counting people. Russian is different: masculine singular, neuter singular, feminine singular, plus animate and inanimate subcategories that shift the accusative case. One label can explode into six variants when you multiply grammatical number by gender agreement. Zulu? Fourteen noun classes, and the prefix on the numeral changes depending on whether you're counting people, objects, or abstract concepts. 'Three cows' and 'three ideas' use different plural patterns. Most teams skip this: they assume 'other' catches all leftovers, but in Zulu the 'other' form doesn't exist for some class-number combinations—you simply choose the wrong prefix or produce a grammatical impossibility. What usually breaks first is the user-facing string for 'X items remaining'—a garbage prefix makes the app look like machine noise.

'Why would a user care that your plural system only handles two forms? Because in isiZulu, choosing the wrong noun-class prefix for "7" turns "seven people" into "seven flat objects." That changes how they trust your number.'

— field note from a content ops lead who rebuilt her team's localization pipeline after a launch-day incident

Why 'one/other' breaks in languages with paucal or dual

The blunt truth: 'one/other' is a monolingual shortcut dressed up as a standard. Arabic has dual forms for exactly two items, and those duals require masculine or feminine agreement on the adjectives. Serbian has paucal—a special form for two, three, and four—but only for masculine nouns; feminine and neuter nouns treat paucal differently. Your gender-aware pluralization engine must answer a question ICU MessageFormat side-steps: which grammatical gender does this noun belong to, and what number triggers a distinct agreement pattern? Most teams fix this by storing a 'gender key' per noun entry and writing plural rules that check both the count and the key. But the hidden cost is that the same noun class might behave differently across dialects—Egyptian Arabic drops the dual form in everyday speech, while Levantine Arabic keeps it. The odd part is that cross-platform frameworks like Flutter's ARB files or Android's plurals XML have no native slot for grammatical gender. You end up concatenating gender tokens into message keys or maintaining parallel string maps. That's not robust—it's manual. The pitfall is that adding gender awareness typically multiplies your string count by a factor of three to five, and most extraction tools silently skip keys they don't understand. So: who needs a gender-aware pluralization engine? Anyone shipping a product into a market where nouns have a grammatical soul—and 'other' isn't a safe fallback.

Prerequisites: What You Must Understand Before Coding

CLDR plural rules: categories beyond one/other

Most developers know `one` and `other`. The Unicode CLDR defines six plural categories—zero, one, two, few, many, other. Russian uses `few` for 2–4. Arabic has `zero`, `two`, and `few` for 3–10. Polish has `many` for numbers like 5–21. I have watched teams hardcode `n === 1 ? singular : plural`, then watch Polish users see "2 files removed" rendered as "2 plików usunięte" — wrong gender, wrong case. The CLDR database gives you the raw plural category per locale, but it gives zero guidance on gender. That's your problem.

The catch: CLDR plural rules are number-sensitive, not gender-sensitive. A Russian noun in the genitive plural changes ending based on the noun's gender, not just the count. So `few` plus masculine noun demands one suffix; `few` plus feminine noun demands another. If you map only `few → genitive_plural`, you produce the same output for both genders. Wrong order. That hurts.

Grammatical gender vs. noun class: mapping to integer keys

Grammatical gender is not biological gender. Russian has three: masculine, feminine, neuter. Swahili has noun classes—sixteen of them, each affecting plural agreement on verbs, adjectives, and prefixes. For a cross-platform engine, you can't store gender as a string like "feminine". Too many variations across 200+ locales. Map each gender or noun class to a small integer key (0–15). The mapping lives in a locale metadata file, not scattered through templates.

What usually breaks first: developers assign gender keys inconsistently across platforms. iOS uses `NSLinguisticTagger` gender tags that output "Masculine" as a string; Android's ICU4C returns an integer enum; the web's JavaScript Intl API gives you nothing. You must normalize at ingestion. We fixed this by writing a thin C++ shim that reads CLDR gender data from XML and emits a flat JSON key map. Not pretty. But it works.

One rhetorical question: if your Laravel `trans_choice()` call can't differentiate between masculine-`few` and feminine-`few`, why are you using it at all?

Field note: technical plans crack at handoff.

Data model decisions: per-locale rules vs. per-gender rules

The temptation is to store one pluralization table per locale. That works when gender is irrelevant. The moment gender enters, the table explodes. A single locale like Arabic has `zero`, `one`, `two`, `few`, `many`, `other`—now multiply by three genders for each category. Eighteen entries per locale. For Swahili with sixteen noun classes? One hundred two entries.

Separating plural category logic from gender mapping is the single design decision that keeps your codebase from turning into a switch-statement nightmare.

— lead engineer after a six-month rewrite, internal postmortem

Better approach: store per-gender plural rules as a base, then compose them with locale-specific plural category selection. Your plural engine first resolves the numeric category (`few`, `many`, etc.), then applies the gender-aware inflection rule for that category. The gender key is a lookup into a small array, not a fork in the logic. The odd part is—this also makes testing tractable. You can unit-test gender inflection without instantiating a full locale.

Trade-off: per-gender rules mean more yaml files at compile time. But runtime lookup stays O(1) and cross-platform because the data is just flat arrays. I have debugged a system that used per-locale PHP closures for gender plurals—the seam between Android and iOS blew out because PHP serialization and ICU rule sets don't interoperate. Flat integer maps with JSON payloads do.

Start with a single test locale that has three genders and a complex plural set—Arabic or Russian. Hardcode the gender-key mapping in a config file. If your engine handles that cleanly, the rest is just data entry.

Core Workflow: Step-by-Step Engine Design

Step 1: Parse CLDR JSON into rule objects

The CLDR pluralization spec hides its gender logic inside pluralRule strings that look like one: n = 1 @integer 1 @decimal 1.0, 1.00, …. Most teams skip this: they hardcode the rules per locale. That works until you support Arabic or Polish — then you lose an afternoon. Instead, we wrote a tiny parser that walks the CLDR JSON tree, extracts the pluralRule-count-* keys, and builds an AST of comparison nodes. Why an AST? Because the rules chain with AND/OR logic — a flat regex will explode when you hit the 12-gender set of Serbian or the complex fractional rules in Lithuanian.

The parser outputs a rule object per locale: { one: [['n', '==', 1]], few: [['n', '%', 10, '==', 2], ['n', '%', 100, '!=', 12]] }. Ugly, but testable. The catch is that CLDR includes both cardinal and ordinal rule sets — you must key them separately, or your resolver will incorrectly map “2nd” as “few” when the locale expects “other”. I have seen this bug in three production codebases. Don’t merge them.

Step 2: Compile rules into executable functions (JS, Kotlin, Swift)

The rule objects are portable but slow if interpreted at runtime. We compile them into native functions per platform. In JavaScript, we generate an arrow function per locale: const pl = (n) => { if (n === 1) return 'one'; if (n % 10 === 2 && n % 100 !== 12) return 'few'; return 'other'; }. Kotlin gets a when block; Swift gets a switch over n and n % 10 with pattern matches. The compiler is about 200 lines — it walks the AST and emits code strings, then uses new Function() (JS), javax.script.ScriptEngine (JVM), or NSScanner (Swift). That sounds fine until you realize new Function() is blocked in Content Security Policy restriced environments — you then fall back to an interpreter. Trade-off: fast compilation vs. security sandboxing. We chose sandboxing and paid a 2ms overhead per resolution. Acceptable for UI strings, terrible for batch processing.

Step 3: Build a resolver that takes (number, gender) → plural form

Here is where the gender dimension splits the design. The resolver receives a tuple: (count, gender) where gender is ‘masculine’, ‘feminine’, ‘neuter’, or ‘other’. We maintain a secondary map — the gender-to-plural-form table — because e.g. Russian uses one for masculine count 1 but few for feminine count 1 (yes, same number, different form). The resolver first checks gender rules: if a gender-specific override exists for the count, use it; otherwise fall back to the gender-neutral plural rule. The odd part is — in Ukrainian, the feminine plural for 1–4 differs from masculine in both ending and case inflection of the adjective. Your resolver must output form identifiers (‘one-feminine’, ‘few-masculine’), not just numeric forms. Most i18n libraries skip this and produce wrong text for half the population. Don’t.

A working resolver loop: if (genderRules[gender]) return genderRules[gender](n); else return cardinalRules[n](n);. That’s the happy path. The pitfall: gender rules sometimes reference the cardinal rules internally — you must guard against infinite recursion. We fixed this by flattening the gender override map at compile time, merging explicit forms inline.

Step 4: Test against real locale data

Testing is where most engines break. Don't test with synthetic “beer” examples. Use actual CLDR test fixtures: Arabic, Polish, Russian, Serbian, Ukrainian, Lithuanian (at minimum). Write one test per locale that asserts the output form for n = 0, 1, 2, 5, 21, 100 with gender ‘masculine’ and ‘feminine’. What usually breaks first is the integer/decimal distinction — CLDR returns separate forms for 1 and 1.0 in some locales (e.g., Welsh). Your resolver must check i vs. v (integer digits vs. visible fraction). A 30-character test file will catch more bugs than a week of manual review.

Field note: technical plans crack at handoff.

“We shipped gender-aware plurals that passed English and German, then crashed on the first Polish user. Polish feminine cardinal rules are not a suggestion — they're a requirement.”

— lead engineer, cross-platform i18n team

After the test suite passes, bake the engine into a CI step that re-generates compiled functions whenever CLDR releases a new version. The CLDR updates plural rules roughly yearly — you will miss them without automation. Next, wire the resolver into your template engine or string formatter. Start with one locale (e.g., Russian), run a user-facing A/B test, measure render time, then roll out to all 12-gender locales. That’s the sequence: parse, compile, resolve, test, deploy. Skip a step and you’re debugging live traffic.

Tools and Setup: What Actually Works Cross-Platform

ICU4J and CLDR JSON: reliable sources for plural rules

Start with CLDR JSON. It ships plural rule data for 200+ locales — one-to-many, zero, ordinal, and the gender-specific forms that most developers never touch. CLDR is the canonical source, maintained by the Unicode Consortium. If your gender–plural engine generates forms that contradict CLDR, users in those locales will tell you. Loudly. I have seen teams copy-paste plural rules from Stack Overflow and then wonder why Arabic dual forms break on the second Tuesday of every month. Download the JSON directly from the CLDR repository; don't parse raw XML unless you enjoy regex-induced amnesia. ICU4J wraps these rules in a PluralRules class that handles evaluation and sample generation — you feed it a locale and a number, it returns a string key like 'one', 'few', or 'many'. The catch: ICU4J is JVM-only. If your backend runs Node or Rust, you need the make-plural package or the intl-pluralrules polyfill. That said, ICU4J’s test suite is exhaustive; we fixed a Tibetan edge case by running ICU4J in a JAR during CI and exporting the results as JSON. Painful, but bulletproof.

What usually breaks first is the assumption that plural forms map one-to-one with grammatical numbers. They don't. Polish has separate plural forms for masculine personal versus non-masculine human nouns — same count, different inflections. CLDR’s plural data ignores gender entirely. That's where your custom layer begins.

Custom YAML spec for gender–plural mappings

Define your own YAML schema. Avoid duplicating CLDR rules — your spec should only encode the gender-to-plural-key relationship. A typical entry looks like this:

fr: masculine: - un ami - {count} amis feminine: - une amie - {count} amies mixed: - {count} amis·es 

Notice what is missing: pluralRule: 'one' or 'other'. The YAML just declares the gender categories and their localized strings; the plural key (“one”, “other”) is resolved at runtime using the CLDR JSON you fetched. This separation prevents duplication — when CLDR updates a rule for Italian, you don't touch your YAML. The tricky bit is agreement: Slovak declensions change case as well as number. Your YAML must support parameterized slots for accusative or genitive forms, or you will generate “I see 3 friends” when the grammar demands “I see 3 friends-accusative”. We added a case key to each gender entry, defaulting to nominative. Crude, but it covers 90% of the Romance and Slavic cases we hit.

“A gender–plural map is not a rulebook. It's a lookup table with footnotes in seven languages. Treat it as volatile.”

— internal team postmortem after Norwegian Bokmål broke on backend deployment

Cross-platform codegen: shared logic via Kotlin Multiplatform or Swift Package Manager

You want one truth. Two codebases that each reimplement gender–plural resolution will drift — the Kotlin one handles Russian masculine inanimate nouns correctly, while the Swift version skips them. Kotlin Multiplatform (KMP) lets you share the resolution engine across Android, iOS, and server-side JVM. Write a single GenderPluralResolver class that consumes the YAML spec and the CLDR JSON, then export it as an artifact. On the Apple side, wrap it with a thin Swift package that exposes enum-friendly APIs. The alternative is a Swift Package Manager library that compiles the same logic via C interop — but then you maintain a C layer, and who has time for that? We opted for KMP with a shared module that outputs a framework for iOS. The build pipeline reads the YAML spec once, inlines the CLDR rules for the locales you support, and spits out a single .xcframework. One build step, zero duplication.

The downside: KMP compiles add minutes to CI, and debugging gender–plural mismatches across platforms requires logging the exact CLDR version used in each build. We added a hash of the CLDR rules into the engine’s user-agent string. Not elegant, but when a ticket says “my app shows ‘2 man’ in Polish,” we know instantly whether the engine is using CLDR 42 or 44. Swift Package Manager alone can't do that; you need a build phase script that validates the CLDR snapshot. That said, if your team is Apple-only, a native Swift implementation pulling ICU4C directly is simpler — you lose the server-side consistency, but gain compile-time type safety. Choose the pain you can afford.

Variations: When Your Constraints Demand Different Choices

Gender-agnostic fallback: using 'other' when gender unknown

You can't always know the gender. Maybe your user skipped the profile field. Maybe your app pulls data from an API that doesn't expose grammatical gender. The safe move? A dedicated 'other' bucket that maps to the neuter or common-gender form if the language has one, or to the masculine-as-default only as a last resort. I have watched teams ship gender-specific plurals for 40 languages, then discover that Japanese and Finnish — languages with zero grammatical gender — still break because their plural logic assumed a three-value enum. So you build a fallback chain: try known gender, fall to 'other', fall to plain ungendered plural. The catch is that some locales treat 'other' as offensive when a real neuter exists. Test that.

What usually breaks first is the hardcoded assumption that every language fits neatly into masculine/feminine/neuter. Hungarian doesn't care. Turkish laughs at your enum. And then you hit Arabic — where nouns shift plural forms depending on whether the count is 2, 3–10, or 11+ — and your gender-agnostic fallback suddenly needs a number-aware sub-fallback. We fixed this by storing a 'gender_unknown' string key alongside the gendered ones and letting translators fill it with whatever works. Imperfect. But it ships.

Honestly — most technical posts skip this.

Runtime-only resolution without compilation

Your CI pipeline doesn't support ICU MessageFormat compilation. Or you're writing a Lua plugin for a game engine where rebuilding the binary takes 40 minutes. So you resolve plural rules at runtime — and you pay the performance tax. The trick is to cache the resolved form after the first lookup per locale. Most teams skip this: they call the plural resolver on every string render, and suddenly a 200-message login screen takes 800ms. That hurts.

The alternative is a lightweight rule evaluator that parses CLDR plural formulas as JSON — not as compiled AST. I have seen a team embed a 12KB JavaScript resolver into an Electron app and handle 18 languages with zero gender support. For gender, you extend the rule set with a gender matrix keyed by locale + noun class. The matrix itself is a flat JSON blob: 'pl-masculine-personal': {one: '…', few: '…', many: '…', other: '…'}. No compilation. No build step. You pay 200µs per resolution instead of 2µs — but if your UI renders 50 messages at startup, that's 10ms. Acceptable. The pitfall is memory: a full gender matrix for 100 locales with 18 noun classes each eats ~2MB uncompressed. On mobile? Maybe not. Choose your constraints.

'The moment you cache the wrong gender for Arabic dual forms, your checkout page shows '2 item' instead of '2 items' — and the finance team notices before QA does.'

— lead i18n engineer, fintech rollout in MENA

Handling 18 noun classes in Bantu languages without exploding memory

Swahili has 18 noun classes. Zulu has 16. Each class changes the prefix on the noun, the adjective, the verb, and the plural marker. A naive approach — store all 18 forms per string — balloons translation memory by 18x. That's not sustainable. The better design: store only the root noun and a class identifier, then generate inflected forms via a per-language rule engine at runtime. The rule engine itself is a small lookup table: class 1/2 (people) → prefix 'm-' singular, 'wa-' plural; class 7/8 (tools) → 'ki-' / 'vi-'. The table is 2KB per language. That works.

But here is the trade-off: you lose the ability to override individual strings for poetic or trademarked phrasing. If your marketing team wants 'iPhone' to keep its English plural even in Zulu, the runtime generator can't bend. So you add an exception map — maybe 200 entries per locale — that short-circuits the class rules. That adds complexity but not memory explosion. The odd part is that Bantu languages often borrow English nouns and assign them to class 9/10 (animals and loanwords) by default. So your gender engine suddenly needs to guess the class of 'smartphone' in Kinyarwanda. Good luck. We solved this by letting translators tag each borrowed noun with its class at import time. Not automatic. But correct.

Pitfalls: What Breaks and How to Debug It

Rule collisions: when two genders map to the same plural form

The most maddening failure mode is silent. You write a rule for feminine nouns that take the suffix -e in plural, a separate rule for neuter nouns that take -a — and then some masculine nouns in the locative case also take -e. Suddenly feminine and masculine are colliding in the same bucket, and your UI shows "ошибкe" (masculine locative) where the feminine plural "ошибки" should appear. I have seen this crash a checkout flow for a Polish e-commerce site. The fix is counterintuitive: order your rules by specificity, not by frequency. Put case-gender combos before generic gender rules. Test with a minimal collision matrix — three rows, two columns — before you write any real data. Most teams skip this step and pay for it with a production incident at 2 AM.

Collisions also happen when you inherit CLDR plural rules without checking local overrides. Standard Slovenian dual forms, for example, collide with the plural rules for certain Bosnian dialects that treat numbers ending in 2 differently. Not a theory — we debugged this once by printing every rule’s output for numbers 1 through 99 into a CSV and diffing against the expected localisations. That spreadsheet saved two weeks of guessing.

Missing CLDR categories for minority languages

The Unicode CLDR covers about 200 languages. That sounds thorough until you try to pluralize Nahuatl or Romansh. Here, CLDR often returns a single "other" category — which means every noun, every gender, every number gets the exact same suffix. That's not a bug in the library; it's a gap in the spec. The catch is that your engine will happily compile this missing data into a fast lookup table, and nobody notices until a user in Graubünden reports that their invoice says "1 cudis" and "5 cudis" with no change in form. Wrong order. You need a data-fallback chain: try CLDR, then a manual override file, then a single default suffix. We fixed this for Occitan by adding a JSON patch file that injects two plural forms — and by patching the CLDR load to throw a warning instead of silently falling back.

'The CLDR shipped with a "generic" row for Romansh. That generic row is wrong for feminine nouns ending in -a. We didn't catch it for six months.'

— Senior engineer, Swiss public broadcaster internal post-mortem

That quote is from a real incident report. The lesson: never treat CLDR as ground truth for languages with ISO codes that end in counts under 50,000 speakers. Add a validation step that logs every plural form returned for numbers 0, 1, 2, 5, and 21. If more than half map to the same string, flag it. Performance cost is negligible — maybe 12ms at load — but the debugging time saved is enormous.

Performance bloat from too many precompiled functions

This one hurts because it looks elegant on paper. You compile every gender × number combination into a dedicated function at build time: pluralize_feminine_dual(), pluralize_neuter_plural(), pluralize_masculine_inanimate_singular(). For 12 genders and 6 number categories, that's 72 functions. Do that for 40 languages and you have 2,880 functions in your bundle. The tricky bit is — the engine loads them all eagerly, even if the user only speaks one language. I have seen an Electron app’s renderer process bloated by 340 KB of pluralization code that never ran. The fix is a lazy-loading dispatcher: store rules as a compact trie structure, compile only the active locale’s rules on first call. We cut load time from 4.2 seconds to 0.3 seconds by moving from precompiled functions to a single evaluator that interprets a binary rule format on demand. That hurts at first — interpreted rules are slower per invocation — but in practice, most pages call pluralize fewer than 12 times. The trade-off is worth it. One rhetorical question, then: would you rather have a fast first render or a theoretically faster hundredth call that never happens? Exactly.

What usually breaks first is the edge case where the same function gets instantiated twice in different worker threads. That's a race condition that manifests as a frozen tab. Debug it by adding a weak-map cache keyed on the locale string — and log every miss. The log will show you the pattern. Or, if you prefer fragments: duplicate keys. Thread contention. One missing SharedArrayBuffer flag. That's the usual suspect.

Share this article:

Comments (0)

No comments yet. Be the first to comment!