Adding a country

Everything about one country is in one file, src/rules/<code>.json, so giving a country rules of its own is a data change: no route, no code. Every ISO country already has a file; one with no address in it is served the default layout.

This page walks through the US file, src/rules/us.json.

Steps

  1. Add an address with a layout to the country's file.
  2. Run pnpm regen. It fills in the format and keeps the generated keys current.
  3. Adjust the format if the country writes an address differently, and add an example.
  4. Add fields where the country differs: a label, a postcode pattern, a phone rule.
  5. Add any new wording to all twelve locale files.
  6. Run pnpm test, and read the golden snapshot's diff before accepting it.

A country new to ISO 3166-1 needs its file first: write { "$schema": "./schema.json", "country": "XX" } as xx.json, then run pnpm regen and node scripts/flags.mjs. CI fails when a file has no flag or is missing from the registry.

The country file

Some keys you write, and some the scripts write for you. Never edit a generated key by hand; the next run puts it back.

Written by hand

KeyDescription
address.layoutThe form's rows, in order
address.fixedValues every address shares
address.formatHow an address is written
address.format_latinThe same, in Latin script
address.upperWhat an envelope capitalizes
address.exampleA real address, for previews
address.example_latinThe same, in Latin script
fields.<name>.labelOther wording for a field
fields.<name>.autocompleteAnother token for a field
fields.postcode.formatThe postcode's pattern and masks
fields.phone_number.formatThe phone rule

Written by the scripts

KeyDescription
currency, regionFrom CLDR, by country-files.mjs
calling_code, example of the phoneFrom libphonenumber, by phone.py
address.format, first time onlyFrom libaddressinput, by formats.mjs

$schema points an editor at src/rules/schema.json, so a misspelt key or field name is underlined as you type. pnpm test runs the same checks and more.

address.layout

The rows of the form, top to bottom, in the order the country writes an address. Fields in one row share a line.

CODE
"layout": [
  ["country"],
  ["first_name", "last_name"],
  ["line1"],
  ["line2"],
  ["city", "state", "postcode"],
  ["phone_number"]
]
  • A field left out is not collected: not shown, not required, not checked.
  • The layout is the whole address, names and phone included, because a billing address is one. It must have country, first_name, last_name and line1.
  • Which fields are required follows from the layout; see How countries differ.

address.fixed

A value every address in the country shares, which the form sends without asking. The US has none; Vatican City fixes both its city and its postcode:

CODE
"fixed": { "city": "Vatican City", "postcode": "00120" }
  • A fixed field is not in the layout.
  • A fixed postcode must pass the postcode pattern, written the way its masks write it.

address.format

How the country writes an address for reading, on an order page or a label: lines of {field} placeholders and text.

CODE
"format": ["{first_name} {last_name}", "{line1}", "{line2}", "{city}, {state} {postcode}", "{country}"]
  • pnpm regen seeds it from libaddressinput when the file has none. After that it is yours: the script never overwrites it.
  • Every field in the layout and every fixed field appears exactly once, and so does {country}. The phone number never does.
  • Add format_latin only where the country writes Latin script differently. The US needs none; Japan's puts the name first. Leave it out when it would match format.
  • upper lists the fields an envelope writes in capitals: ["city", "state"] in the US.

address.example

A real address in the country, in its own script, for previews of the form and the written address.

CODE
"example": {
  "first_name": "Jane",
  "last_name": "Doe",
  "line1": "1600 Pennsylvania Avenue NW",
  "city": "Washington",
  "state": "DC",
  "postcode": "20500"
}
  • Use a public building: a central post office, a parliament, a national museum. Never a private home. Use a placeholder name common in the country's own examples.
  • Give every field the form requires. The postcode must pass the pattern, and a state must be one of the state codes.
  • Add example_latin for a country that writes in another script.

fields.name.label

Other wording for a field, as a label id from src/locales/en.json:

CODE
"fields": { "postcode": { "label": "fields.postcode.zip_code" } }
  • Only line3, city, state and postcode can be relabelled. The street lines read the same everywhere, so "the address" is never Address in one country and Street and house number in the next.
  • A new wording needs a new label id; see New wording.

fields.name.autocomplete

Another HTML autocomplete token for a field that is not what its name suggests. No country uses one today; Thailand did, and How countries differ says why it stopped.

fields.postcode.format

The postcode's pattern, example and masks.

CODE
"format": {
  "pattern": "^[0-9]{5}([0-9]{4}|[0-9]{6})?$",
  "example": "90210",
  "masks": ["#####", "#####-####", "#####-######"]
}
  • pattern is matched against the compact postcode: upper case, spaces and hyphens removed. It is anchored with ^ and $.
  • example is a real postcode as the country writes it. The invalid message shows it.
  • masks place the separator: # is one letter or digit, and the mask with as many # as the postcode has characters is used. The US has one per length, so 941031234 is written 94103-1234.
  • The pattern must accept every example postcode libaddressinput gives for the country. Run node scripts/postcode-examples.mjs after adding one; a test fails on any it refuses.
  • How each state's postcodes start is not written in the file. It is fetched from libaddressinput for every country that collects a state and has a pattern: run uv run scripts/postcode-regions.py --out src after adding one. A test fails if a prefix refuses one of the state's own example postcodes, or the country's example address.

A country with no layout of its own is served no pattern, and its postcode is accepted as typed. A wrong pattern refuses a real address at the last step of a checkout, so a country is validated only once it has a file of its own, where a test checks its pattern.

fields.phone_number.format

The phone rule: how the number is written as it is typed, and what is clearly not one. The E.164 a form submits comes from POST /v1/validate, not from the rule.

CODE
"format": {
  "calling_code": "1",
  "national_prefix": "1",
  "masks": [{ "mask": "(###) ###-####" }],
  "pattern": "^[0-9]{10,11}$",
  "example": "(201) 555-0123"
}
  • calling_code, national_prefix and example come from libphonenumber; phone.py keeps the calling code and example current. The national_prefix of a curated rule is the one its masks are written with, and is kept by hand.
  • masks has one entry per number shape, picked by a start regex; the last has none and is the default.
  • Keep pattern loose: the order API validates the number. A test fails if it refuses any of libphonenumber's example numbers for the country.
  • A file with no layout of its own has only calling_code, national_prefix (where the country has one) and example, all written by scripts/phone.py; do not edit them by hand.
  • test/fixtures/phone-parsing.json types every libphonenumber example number the ways a shopper does (national, bare, with its calling code, dialled from abroad, a +1 island's seven local digits), and a test holds POST /v1/validate to the E.164 phonenumbers makes of each.

New wording

Every label id must exist in src/locales/en.json. Reuse a variant when the wording matches. When it does not, add one under the field, with its optional label and its errors, to all twelve locale files:

CODE
"ward": {
  "label": "Ward",
  "label_optional": "Ward (optional)",
  "errors": {
    "not_selected": "Select a ward",
    "invalid": "Choose a ward from the list"
  }
}

A variant whose label matches default in every language says nothing new, and a test fails on it. The errors each field can raise are in src/locales/README.md.

State lists

state is a list where ISO 3166-2 has subdivisions for the country, and free text where it has none. Putting state in the layout is the whole change: pnpm regen reads the country files and adds the list.

Reasons

A reason for a choice goes in Country notes, not in the file: JSON holds no comments, and a response should not carry prose.

Checks

pnpm test checks every country file. It fails on:

CheckDescription
NamesA misspelt key, field, token or label id
LayoutA field described but not laid out, or laid out twice
FixedA fixed field in the layout, or a bad fixed postcode
FormatA field left out, written twice, or not collected
ExampleA missing required field, bad postcode or state
PostcodeAn example or libaddressinput postcode refused
PhoneA libphonenumber example number refused
WordingA label id missing from any locale
GeneratedA bad currency or region code
UncuratedA rule in a file with no layout of its own

test/golden.test.ts holds the resolved rules of every country. Adding a country changes that country's lines and its row in the country list, and nothing else; a wider diff means a change reached further than meant. Read it before accepting it with pnpm test -u.

Then look at the result:

CODE
pnpm dev
curl 'localhost:8787/v1/countries/US?lang=en' | jq

Keeping up with upstream

The layouts, formats and wording are ours. What moves underneath them is reference data: ISO 3166-2 renames a subdivision, CLDR changes a currency, libphonenumber renumbers a country. ./scripts/check-data-drift.sh regenerates it from the newest sources and reports what changed. See CONTRIBUTING.md.