Locale Validation Guide

Copy Markdown View Source

This guide explains how to turn a locale identifier — a string, an atom, or a Localize.LanguageTag — into a validated, canonical form, and which of the several entry points (Localize.validate_locale/1, Localize.LanguageTag.parse/1, Localize.LanguageTag.new/1) you actually want.

Overview

Localize.validate_locale/1 is the one canonical path. Every Localize formatting function resolves its :locale option through it, and application code that accepts locale identifiers from users, HTTP headers, or configuration should do the same:

iex> {:ok, tag} = Localize.validate_locale("en-US")
iex> tag.cldr_locale_id
:en
iex> tag.canonical_locale_id
"en-US"

iex> {:ok, tag} = Localize.validate_locale(:"fr-CA")
iex> tag.cldr_locale_id
:"fr-CA"

Validation performs, in one pass:

  • Parsing — the identifier is parsed against the BCP 47 grammar, with POSIX-style underscores normalized to hyphens first ("pt_BR" is accepted as "pt-BR").

  • Alias resolution — deprecated subtags are replaced by their canonical successors per TR35: "iw" becomes "he", "sr-YU" becomes "sr-RS".

  • Likely-subtag resolution — the language, script, and territory fields are populated with the maximized form, so downstream code can always read them.

  • -u- extension parsing — Unicode extension keywords are validated and decoded into a Localize.LanguageTag.U struct on the tag's :locale field.

  • CLDR locale matching — the tag is matched against the candidate locales (all ~766 CLDR locales, or your configured :supported_locales) to populate :cldr_locale_id, the key used for all data lookup.

  • Caching — the result is cached in ETS, so repeated validation of the same identifier costs about a microsecond.

iex> {:ok, tag} = Localize.validate_locale("iw")
iex> {tag.language, tag.canonical_locale_id, tag.cldr_locale_id}
{:he, "he", :he}

iex> {:ok, tag} = Localize.validate_locale("en")
iex> {tag.language, tag.script, tag.territory}
{:en, :Latn, :US}

iex> {:ok, tag} = Localize.validate_locale("pt_BR")
iex> {tag.canonical_locale_id, tag.cldr_locale_id}
{"pt-BR", :pt}

Note the last example: canonical_locale_id preserves the caller's request in canonical syntax ("pt-BR" stays "pt-BR"), while cldr_locale_id is the CLDR data locale that serves it (:pt, because Brazilian Portuguese is CLDR's base pt). Locale identity and data lookup key off cldr_locale_id; display and round-tripping key off canonical_locale_id.

The three id fields

FieldContentExample for input "iw-US"
:requested_locale_idThe input, verbatim."iw-US"
:canonical_locale_idCanonical syntax: aliases resolved, subtags ordered — neither maximized nor minimized."he-US"
:cldr_locale_idThe matched CLDR data locale, as an atom.:he

Unmatchable languages resolve to root

When the language is valid but CLDR has no data for it, the tag resolves to the root locale :und rather than to a wrong language:

iex> {:ok, tag} = Localize.validate_locale("tlh")
iex> {tag.canonical_locale_id, tag.cldr_locale_id}
{"tlh", :und}

A string that is not a well-formed BCP 47 tag, or whose subtags are not in the CLDR validity sets, returns an error:

iex> {:error, error} = Localize.validate_locale("xyzzy")
iex> error.__struct__
Localize.InvalidLocaleError

-u- extensions

BCP 47 -u- extension keywords — calendar, numbering system, hour cycle, collation, region override, and the rest — are decoded into a Localize.LanguageTag.U struct during validation. Formatting functions honour them automatically:

iex> {:ok, tag} = Localize.validate_locale("th-u-nu-thai-ca-buddhist")
iex> {tag.locale.nu, tag.locale.ca}
{:thai, :buddhist}

iex> {:ok, tag} = Localize.validate_locale("en-u-hc-h23")
iex> tag.locale.hc
:h23

Localize.LanguageTag.to_string/1 re-encodes the whole tag, extensions included, in canonical order:

iex> {:ok, tag} = Localize.validate_locale("th-u-nu-thai-ca-buddhist")
iex> Localize.LanguageTag.to_string(tag)
"th-u-ca-buddhist-nu-thai"

Standalone -u- strings

Some interchange formats carry the extension subtags without a full locale identifier. Localize.LanguageTag.U.parse/1 parses such a string on its own, with or without the leading u- singleton, applying the same validation and canonicalization as validate_locale/1; Localize.LanguageTag.U.encode/1 goes back to canonical BCP 47 key/value pairs:

iex> {:ok, u} = Localize.LanguageTag.U.parse("ca-gregory-hc-h23")
iex> {u.ca, u.hc}
{:gregorian, :h23}

iex> {:ok, u} = Localize.LanguageTag.U.parse("u-nu-thai")
iex> u.nu
:thai

iex> {:ok, u} = Localize.LanguageTag.U.parse("ca-gregory-hc-h23")
iex> Localize.LanguageTag.U.encode(u)
[{"ca", "gregory"}, {"hc", "h23"}]

Note that decoding canonicalizes values ("gregory" decodes to :gregorian) and encoding restores the BCP 47 spelling.

Which one do I want?

Localize.LanguageTag also exposes parse/1 and new/1, which do progressively less than validate_locale/1:

LanguageTag.parse/1LanguageTag.new/1Localize.validate_locale/1
BCP 47 grammar parseYesYesYes
Subtag validity checkYesYesYes
Alias resolution / canonicalizationPartial (subtag aliases)YesYes
Likely-subtag maximizationNoYesYes
-u- keyword decoding into LanguageTag.UNoYesYes
cldr_locale_id resolutionNoYesYes
Respects :supported_locales configNoNoYes
ETS result cacheNoNoYes
Accepts atoms and LanguageTag structsNo (binary only)No (binary only)Yes
iex> {:ok, parsed} = Localize.LanguageTag.parse("en-US")
iex> {parsed.script, parsed.cldr_locale_id}
{nil, nil}

iex> {:ok, tag} = Localize.LanguageTag.new("zh-TW")
iex> {tag.language, tag.script, tag.territory, tag.cldr_locale_id}
{:zh, :Hant, :TW, :"zh-Hant"}

In short: use validate_locale/1 unless you have a specific reason not to. parse/1 is the raw syntactic layer, useful when you need to inspect exactly what the caller wrote before any resolution. new/1 is a fully resolved tag without the supported-locales restriction or the cache — useful in build tooling that must see all CLDR locales regardless of application configuration.

Supported vs available vs known

Localize uses a consistent vocabulary for locale (and other) inventories:

  • known — the locale-independent CLDR universe, e.g. Localize.all_locale_ids/0 (~766 locales, or filtered by coverage level with Localize.all_locale_ids(:modern)).

  • available — present in the CLDR release: Localize.available_locale_id?/1 answers whether an identifier is a CLDR locale id.

  • supported — what your application is configured to serve: Localize.supported_locales/0 reflects config :localize, supported_locales: [...], falling back to all CLDR locales when unset. When configured, validate_locale/1 matches only against this list.

iex> Localize.available_locale_id?(:en)
true

iex> Localize.available_locale_id?("tlh")
false

Restricting :supported_locales is the recommended production setup — it bounds locale matching to what you can actually serve and makes validate_locale/1 resolve near-miss identifiers to your supported set. See the README configuration section for the accepted entry forms (atoms, wildcards, coverage levels, Gettext backends).

Locale matching

Localize.LanguageTag.best_match/3 implements the CLDR language matching algorithm directly, for cases like negotiating an Accept-Language header against the locales your application ships:

iex> Localize.LanguageTag.best_match("en-AU", ["en", "en-GB", "fr"])
{:ok, "en-GB", 3}

iex> Localize.LanguageTag.best_match("pt-PT", ["pt-BR", "es", "fr"])
{:ok, "pt-BR", 5}

The score is the CLDR match distance — 0 is exact, small values are regional variants, and 80+ means a different language. With the default threshold the algorithm always returns some supported locale (a distant match beats no match, per the CLDR specification); pass an explicit threshold for strict matching:

iex> Localize.LanguageTag.best_match("de", ["en", "de", "fr"], 0)
{:ok, "de", 0}

iex> {:error, _} = Localize.LanguageTag.best_match("ja", ["en", "fr"], 20)

Localize.LanguageTag.match_distance/2 returns the raw distance between two locales:

iex> Localize.LanguageTag.match_distance("en-AU", "en-GB")
3

iex> Localize.LanguageTag.match_distance("en", "fr")
84