Plurality (Plurality v0.2.3)

Copy Markdown View Source

Fast, zero-regex English noun inflection for Elixir.

Operates on English nouns only. Other parts of speech (pronouns, adjectives, adverbs, etc.) are not supported and will produce undefined results.

Plurality provides 99%+ accuracy on business and technical English, using compile-time binary pattern matching instead of runtime regex.

Quick start

Plurality.pluralize("leaf")                    #=> "leaves"
Plurality.singularize("leaves")                #=> "leaf"
Plurality.plural?("leaves")                    #=> true
Plurality.singular?("leaf")                    #=> true
Plurality.inflect("leaf", 2)                   #=> "leaves"
Plurality.inflect("leaf", 1)                   #=> "leaf"

Safe pluralization

Pass check: true to avoid double-pluralizing words that are already plural.

Plurality.pluralize("children", check: true)   #=> "children"
Plurality.pluralize("people", check: true)     #=> "people"
Plurality.pluralize("leaf", check: true)       #=> "leaves"

Case preservation

Input casing is detected and applied to the output automatically. Three styles are recognized: ALL CAPS, Title Case, and lowercase.

Plurality.pluralize("LEAF")                    #=> "LEAVES"
Plurality.pluralize("Leaf")                    #=> "Leaves"
Plurality.singularize("WOMEN")                 #=> "WOMAN"
Plurality.singularize("Children")              #=> "Child"

Uncountable words

Words like "sheep", "software", and "news" are returned unchanged by both pluralize/2 and singularize/1. Detection functions plural?/1 and singular?/1 return true for uncountables since they are valid in either context.

Plurality.pluralize("sheep")                   #=> "sheep"
Plurality.singularize("sheep")                 #=> "sheep"
Plurality.plural?("sheep")                     #=> true
Plurality.singular?("sheep")                   #=> true

Compound nouns

Multi-word nouns are split on the last space and the final word is inflected. Known multi-word irregulars in the data (e.g., "coup d'etat") take priority over splitting.

Plurality.pluralize("status code")             #=> "status codes"
Plurality.pluralize("field mouse")             #=> "field mice"
Plurality.singularize("ice creams")            #=> "ice cream"

Domain customization

Use Plurality.Custom to define a module with your own irregulars and uncountables. Overrides compile into function heads that take priority over the built-in data.

defmodule MyApp.Inflection do
  use Plurality.Custom,
    irregulars: [{"regex", "regexen"}],
    uncountables: ["kubernetes"]
end

MyApp.Inflection.pluralize("regex")            #=> "regexen"
MyApp.Inflection.pluralize("kubernetes")       #=> "kubernetes"
MyApp.Inflection.pluralize("leaf")             #=> "leaves"

See Plurality.Custom for full documentation.

Classical mode

Pass classical: true to get Latin/Greek plural forms instead of modern English forms. Default behavior is unchanged — classical mode is opt-in.

Plurality.pluralize("aquarium")                  #=> "aquariums"
Plurality.pluralize("aquarium", classical: true) #=> "aquaria"
Plurality.pluralize("trauma", classical: true)   #=> "traumata"
Plurality.pluralize("formula", classical: true)  #=> "formulae"

Singularization handles both forms automatically, no option needed:

Plurality.singularize("aquariums")  #=> "aquarium"
Plurality.singularize("aquaria")    #=> "aquarium"

App-wide config

To make all Plurality.* functions automatically delegate to your custom module, set it in your application config:

# config/config.exs
config :plurality, custom_module: MyApp.Inflection

With this config, any code calling Plurality.pluralize/2 — including third-party libraries — will use your custom overrides transparently.

To enable classical mode globally:

# config/config.exs
config :plurality, classical: true

Per-call classical: true/false overrides the app-wide setting.

Ash integration

If your project uses Ash, Plurality ships optional changes, validations, and calculations that compile away to nothing if Ash is not loaded. See:

Architecture

Plurality uses a three-tier resolution engine (originated by Conway's 1998 paper, same architecture as Rails and pluralize.js):

  1. Uncountables (MapSet, O(1)) — word returned unchanged
  2. Irregulars (Map, O(1)) — direct lookup from merged cross-ecosystem data
  3. Suffix rules (last-byte dispatch) — BEAM select_val jump table, zero regex

All data is compiled into module attributes at build time from TSV/TXT files in priv/. There is zero runtime file I/O and zero regex execution.

Data

Plurality ships with ~1,110 irregular pairs, ~1,022 uncountable words, and 108 suffix rules — curated from multiple sources across the Elixir, Ruby, JavaScript, Go, C#, and Rust ecosystems, then verified against Oxford and Merriam-Webster dictionaries. All data is loaded at compile time from priv/data/.

Summary

Functions

Inflects a word based on a numeric count.

Returns true if the word is in plural form (or is uncountable).

Converts an English noun to its plural form.

Returns true if the word is in singular form (or is uncountable).

Converts an English noun from plural to singular form.

Types

pluralize_opts()

@type pluralize_opts() :: [check: boolean(), classical: boolean()]

Options for pluralize/2 and inflect/3.

  • :check — when true, tests whether the word is already in plural form before transforming it. If the word is already plural, it is returned unchanged. This prevents double-pluralization errors like "children""childrens". Defaults to false.

  • :classical — when true, uses classical Latin/Greek plural forms instead of modern English forms (e.g., "aquarium""aquaria" instead of "aquariums"). When false or omitted, falls back to the app-wide config config :plurality, classical: true, which defaults to false.

Functions

inflect(word, count, opts \\ [])

@spec inflect(word :: String.t(), count :: integer(), opts :: pluralize_opts()) ::
  String.t()

Inflects a word based on a numeric count.

Returns the singular form when count is exactly 1, and the plural form for all other values (including 0, negative numbers, and numbers greater than 1).

If a custom module is configured, delegates to that module's inflect/3.

This follows standard English convention where zero and plural counts use the plural form: "0 items", "2 items", but "1 item".

Options

Accepts the same options as pluralize/2 (:check, :classical). Options are passed through to pluralize/2 when the count is not 1.

Examples

iex> Plurality.inflect("leaf", 1)
"leaf"

iex> Plurality.inflect("leaf", 2)
"leaves"

iex> Plurality.inflect("leaf", 0)
"leaves"

iex> Plurality.inflect("child", 1)
"child"

iex> Plurality.inflect("child", 3)
"children"

iex> Plurality.inflect("sheep", 1)
"sheep"

iex> Plurality.inflect("sheep", 100)
"sheep"

iex> Plurality.inflect("aquarium", 2, classical: true)
"aquaria"

iex> Plurality.inflect("aquarium", 1, classical: true)
"aquarium"

plural?(word)

@spec plural?(word :: String.t()) :: boolean()

Returns true if the word is in plural form (or is uncountable).

Uncountable words like "sheep" and "software" return true for both plural?/1 and singular?/1, since they are valid in either context.

If a custom module is configured, delegates to that module's plural?/1.

Detection strategy

Detection is derived from transformation (inspired by pluralize.js):

  1. Uncountable? → true
  2. Known irregular plural (in the plural→singular map)? → true
  3. Try singularize/1 — if it produces a different word, and pluralize/1 on that result gives back the original, then true

Examples

iex> Plurality.plural?("leaves")
true

iex> Plurality.plural?("children")
true

iex> Plurality.plural?("leaf")
false

iex> Plurality.plural?("sheep")
true

iex> Plurality.plural?("")
false

pluralize(word, opts \\ [])

@spec pluralize(word :: String.t(), opts :: pluralize_opts()) :: String.t()

Converts an English noun to its plural form.

Returns the word unchanged if it is uncountable (e.g., "sheep", "software"). Preserves the casing style of the input (ALL CAPS, Title Case, or lowercase).

If a custom module is configured via config :plurality, custom_module: MyApp.Inflection, this function delegates to that module's pluralize/2 instead.

Options

  • :check (boolean()) - When true, checks whether the word is already plural and returns it unchanged if so. Prevents double-pluralization (e.g., "children" staying "children" instead of becoming "childrens"). Defaults to false.

  • :classical (boolean()) - When true, uses classical Latin/Greek plural forms instead of modern English forms. When omitted, falls back to config :plurality, classical: true (default false).

Resolution order

  1. Uncountable? → return unchanged
  2. check: true and already a known irregular plural? → return unchanged
  3. classical: true and word has a known classical form? → return classical plural
  4. Known irregular singular? → return the mapped plural
  5. check: true and rule-based detection says plural? → return unchanged
  6. Compound noun? → split on last space, inflect last word
  7. Apply suffix rules (last-byte dispatch, classical-aware)

Examples

iex> Plurality.pluralize("leaf")
"leaves"

iex> Plurality.pluralize("child")
"children"

iex> Plurality.pluralize("sheep")
"sheep"

iex> Plurality.pluralize("schema")
"schemas"

iex> Plurality.pluralize("children", check: true)
"children"

iex> Plurality.pluralize("LEAF")
"LEAVES"

iex> Plurality.pluralize("Leaf")
"Leaves"

iex> Plurality.pluralize("status code")
"status codes"

iex> Plurality.pluralize("")
""

Classical mode

iex> Plurality.pluralize("aquarium", classical: true)
"aquaria"

iex> Plurality.pluralize("formula", classical: true)
"formulae"

iex> Plurality.pluralize("trauma", classical: true)
"traumata"

iex> Plurality.pluralize("cactus")
"cactuses"

iex> Plurality.pluralize("leaf", classical: true)
"leaves"

singular?(word)

@spec singular?(word :: String.t()) :: boolean()

Returns true if the word is in singular form (or is uncountable).

Uncountable words return true for both plural?/1 and singular?/1.

If a custom module is configured, delegates to that module's singular?/1.

Detection strategy

  1. Uncountable? → true
  2. Known irregular singular (in the singular→plural map)? → true
  3. Known irregular plural (in the plural→singular map)? → false
  4. Otherwise, check whether the word appears to already be plural via rule-based round-tripping

Examples

iex> Plurality.singular?("leaf")
true

iex> Plurality.singular?("child")
true

iex> Plurality.singular?("leaves")
false

iex> Plurality.singular?("sheep")
true

iex> Plurality.singular?("")
false

singularize(word)

@spec singularize(word :: String.t()) :: String.t()

Converts an English noun from plural to singular form.

Returns the word unchanged if it is uncountable. Preserves casing style.

If a custom module is configured via config :plurality, custom_module: MyApp.Inflection, this function delegates to that module's singularize/1 instead.

For words that appear in both the uncountables set and the irregular plurals map (e.g., "data", "graffiti"), the irregular reverse lookup takes priority so that singularization resolves correctly:

Plurality.singularize("data")      #=> "datum"
Plurality.singularize("graffiti")  #=> "graffito"

Singularization is mode-independent — it handles both modern and classical plural forms without needing a classical: option:

Plurality.singularize("aquariums")  #=> "aquarium"
Plurality.singularize("aquaria")    #=> "aquarium"

Resolution order

  1. Known irregular plural (including classical forms)? → return the mapped singular
  2. Uncountable? → return unchanged
  3. Compound noun? → split on last space, singularize last word
  4. Apply suffix rules (last-byte dispatch)

Examples

iex> Plurality.singularize("leaves")
"leaf"

iex> Plurality.singularize("children")
"child"

iex> Plurality.singularize("sheep")
"sheep"

iex> Plurality.singularize("taxes")
"tax"

iex> Plurality.singularize("statuses")
"status"

iex> Plurality.singularize("WOMEN")
"WOMAN"

iex> Plurality.singularize("status codes")
"status code"

iex> Plurality.singularize("")
""

Classical plural forms

iex> Plurality.singularize("aquaria")
"aquarium"

iex> Plurality.singularize("antennae")
"antenna"

iex> Plurality.singularize("traumata")
"trauma"