The Localize ecosystem

Copy Markdown View Source

Localize is best understood as an additional set of structured data types — a currency amount, a unit of measure, a postal address, a phone number, a locale — together with the operations those types need in order to be useful. What makes them a family is not that they are complicated, but that none of them can be rendered, read back, or sorted without knowing whose conventions to apply. A price is $1,234.56 or 1.234,56 € depending on the reader; a date is 5/16/2026 or 16.05.2026; a list is "a, b, and c" or "a, b und c". The value is the same. The presentation is not.

Having established those types, the same treatment extends outward to the types Elixir already has. An integer, a Date, a String and a Date.Range are every bit as locale-dependent as a money amount when they meet a human being, so Localize formats, parses and orders them too. The result is a single vocabulary across both sets: whatever the value, you ask the same four questions of it.

Most of it can be tried without installing anything: the playground runs the formatting, parsing and input components live in the browser against any locale.

The operations

  • Formatting turns a value into text for a particular locale — the operation that has no locale-independent answer.

  • Parsing reverses it, reading locale-formatted text back into a value. It is the operation most often missing from localization libraries, and the one that matters as soon as a user types something into a form.

  • Ordering arranges values. For numbers and dates this is arithmetic and needs no locale; for text it is the Unicode Collation Algorithm with CLDR tailoring, where the locale changes the answer (in Swedish ä sorts after z; in German it sorts with a).

  • HTML input is the browser-side counterpart of parsing: a form control that lets a user enter the value under their own conventions, formatting as they type and submitting something the server can parse. A German user typing 1.234,56 into a plain <input type="number"> gets nothing useful; a locale-aware component gets it right.

  • Serialization stores a value in a database and reads it back as the same value, rather than as a string someone has to reassemble.

Formatting and parsing are complete across the family. Ordering and serialization are complete for everything it makes sense for. HTML input is the newest and least complete — the components that exist are listed below, and the gaps in that column are the roadmap.

Elixir's types

TypeFormattingParsingOrderingHTML inputSerialization
IntegerLocalizeLocalizeEnum.sort<.number_input>Ecto
FloatLocalizeLocalizeEnum.sort<.number_input>Ecto
DecimalLocalizeLocalizeEnum.sort<.number_input>Ecto
StringLocalizeEcto
DateLocalizeCalendricalEnum.sort<.date_input>Ecto
TimeLocalizeCalendricalEnum.sortEcto
DateTimeLocalizeCalendricalEnum.sortEcto
NaiveDateTimeLocalizeCalendricalEnum.sortEcto
DurationLocalizeEnum.sortLocalize
Date.RangeLocalizeCalendricalEnum.sort<.date_range_input>Localize
RangeLocalizeEnum.sortLocalize
ListLocalizeEnum.sortEcto

A String is the one row with no formatting or parsing: it is already text, so there is nothing to render it into or read it out of. It is also the only row where ordering is a Localize operation rather than a comparison, which is the point — collation is where a locale changes the sort order of values that are otherwise identical.

Duration, Range and Date.Range say Localize under serialization because PostgreSQL has interval, int8range and daterange but Ecto has no types mapping Elixir's values onto them; localize_sql supplies those. Everything else in the table is a type Ecto already stores.

Localize's types

TypeFormattingParsingOrderingHTML inputSerialization
Localize.LanguageTagLocalizeLocalizeEnum.sortLocalize
Localize.CurrencyLocalizeLocalizeEnum.sort<.currency_picker>Localize
MoneyLocalizeLocalizeEnum.sort<.money_input>Localize
Localize.UnitLocalizeLocalizeEnum.sort<.unit_input>Localize
Localize.DurationLocalizeEnum.sortLocalize
Localize.TerritoryLocalizeLocalizeEnum.sortLocalize
Localize.ScriptLocalizeLocalizeEnum.sortLocalize
Localize.AddressLocalizeLocalizeLocalizeLocalize
Localize.PhoneNumberLocalizeLocalizeEnum.sortLocalize
Localize.PersonNameLocalizeLocalizeLocalize
MF2 messageLocalizeMF2 editorGettext

Addresses and person names order through Localize because sorting them means collating their formatted text, which is the String case again. Both are stored as jsonb, keeping their parts separate — a stored name renders as "Dr. Herbert Fritz von Müller" or "Müller, Herbert" depending on the locale and format asked for at display time, which storing a formatted string would forfeit.

An MF2 message is the one type that is neither parsed from its output nor stored in a database: a formatted message is prose, and the message itself is authored and distributed as a Gettext translation rather than a column value.

Reading the columns

Enum.sort is not a shortfall. Elixir's Enum.sort/2 accepts a module implementing compare/2, and the ecosystem's types implement exactly that — so ordering needs no special API:

iex> Enum.sort([Money.new(:USD, 30), Money.new(:USD, 10)], Money)
[Money.new(:USD, "10"), Money.new(:USD, "30")]

The same call shape sorts units, converting between compatible units before comparing — three feet is shorter than one metre, so it sorts first despite the larger number:

iex> {:ok, feet} = Localize.Unit.new(3, "foot")
iex> {:ok, metre} = Localize.Unit.new(1, "meter")
iex> Enum.sort([metre, feet], Localize.Unit) |> Enum.map(& &1.name)
["foot", "meter"]

For text, ordering is the locale's collation:

iex> Localize.Collation.sort(["ä", "z", "a"], locale: :sv)
["a", "z", "ä"]

iex> Localize.Collation.sort(["ä", "z", "a"], locale: :de)
["a", "ä", "z"]

Parsing is the round trip. Where a type says Localize or Calendrical under parsing, formatted output can be read back:

iex> Localize.Number.to_string(1234.56, locale: :de)
{:ok, "1.234,56"}

iex> Localize.Number.parse("1.234,56", locale: :de)
{:ok, 1234.56}

Dates, times and datetimes are parsed by Calendrical, which reads locale-formatted input across CLDR's calendars — including relative and partial forms such as "Q2 2026" — and is the one sibling that also parses a range:

iex> Calendrical.Date.parse("16.05.2026", locale: :de)
{:ok, ~D[2026-05-16]}

Serialization keeps the type. A column declared with a Localize Ecto type loads as the value, not as text to re-parse. Most map to ordinary text or jsonb and need no migration beyond the column; money and units map to a PostgreSQL composite type and gain database-side sum, avg, min and max aggregates that refuse to add euros to yen. See localize_sql.

The libraries

Each library adds types, operations, or both. They share the locale resolution, CLDR data and configuration of Localize itself, so a locale set once applies across all of them.

  • localize — the core: number, date, time, unit, list and interval formatting; number and unit parsing; collation; plural rules; RBNF; display names for territories, languages, scripts and currencies; and MessageFormat 2.

  • calendrical — CLDR calendars beyond the ISO one, and locale-aware parsing of dates, times, datetimes and date ranges.

  • ex_money and ex_money_sql — the Money type, its arithmetic and formatting, and its database storage with tag-guarded aggregates.

  • localize_sql — Ecto types for the whole set, the tagged-decimal aggregate machinery that ex_money_sql builds on, and locale-aware COLLATE for queries on PostgreSQL and SQLite.

  • localize_address — postal addresses: parsing unstructured input, and formatting per each territory's conventions.

  • localize_phone_number — phone numbers: parsing, validation and formatting via libphonenumber.

  • localize_person_names — person names formatted to CLDR's per-locale ordering, and its handling of given, surname and honorific order.

  • localize_web — the Phoenix layer: plugs that resolve the locale from the request, localized routes, and HTML helpers. It is what puts the right locale in Localize.get_locale/0 for the duration of a request, so everything above formats correctly without being passed a locale explicitly.

Form input

Formatting and parsing assume the value has already reached the server. Getting it there is its own problem: a plain <input type="number"> rejects 1.234,56 from a German user, and a native date picker offers no Buddhist or Persian calendar. These libraries close that gap with Phoenix LiveView components that format as the user types and submit something the server can parse.

They are the newest part of the family and still 0.1.x — the APIs may move, and coverage is partial. The inputs playground demonstrates the components live against any locale.

  • localize_inputs_core — the shared base the input libraries build on: common exception types, Gettext backend, CSS variable tokens and JS bootstrap helpers.

  • localize_number_inputs<.number_input> for decimals and integers, and <.unit_input> with a <.unit_picker> for a number paired with a unit of measure. Live formatting is AutoNumeric-backed.

  • localize_datetime_inputs<.date_input>, <.date_range_input>, <.date_range_picker> and a DatePickerLive component, built on Calendrical so the picker works in Gregorian, Buddhist, Japanese imperial, Islamic, Persian, Hebrew and ROC calendars.

  • ex_money_input<.money_input> and <.currency_picker>, with an Ecto changeset bridge so the submitted value casts straight into a Money field.

Authoring MF2 messages

An MF2 message is written rather than entered, so its "input" is an editor rather than a form control.

  • mf2_treesitter — the tree-sitter grammar for MessageFormat 2, which gives incremental parsing and error recovery suitable for an editor.

  • mf2_wasm_editor — that grammar compiled to WASM with a LiveView hook, so an MF2 textarea highlights syntax in the browser with no round trip per keystroke.