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
- Add an
addresswith alayoutto the country's file. - Run
pnpm regen. It fills in theformatand keeps the generated keys current. - Adjust the
formatif the country writes an address differently, and add anexample. - Add
fieldswhere the country differs: a label, a postcode pattern, a phone rule. - Add any new wording to all twelve locale files.
- 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
| Key | Description |
|---|---|
address.layout | The form's rows, in order |
address.fixed | Values every address shares |
address.format | How an address is written |
address.format_latin | The same, in Latin script |
address.upper | What an envelope capitalizes |
address.example | A real address, for previews |
address.example_latin | The same, in Latin script |
fields.<name>.label | Other wording for a field |
fields.<name>.autocomplete | Another token for a field |
fields.postcode.format | The postcode's pattern and masks |
fields.phone_number.format | The phone rule |
Written by the scripts
| Key | Description |
|---|---|
currency, region | From CLDR, by country-files.mjs |
calling_code, example of the phone | From libphonenumber, by phone.py |
address.format, first time only | From 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.
"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_nameandline1. - 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:
"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.
"format": ["{first_name} {last_name}", "{line1}", "{line2}", "{city}, {state} {postcode}", "{country}"]pnpm regenseeds 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_latinonly where the country writes Latin script differently. The US needs none; Japan's puts the name first. Leave it out when it would matchformat. upperlists 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.
"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_latinfor a country that writes in another script.
fields.name.label
Other wording for a field, as a label id from src/locales/en.json:
"fields": { "postcode": { "label": "fields.postcode.zip_code" } }- Only
line3,city,stateandpostcodecan be relabelled. The street lines read the same everywhere, so "the address" is neverAddressin one country andStreet and house numberin 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.
"format": {
"pattern": "^[0-9]{5}([0-9]{4}|[0-9]{6})?$",
"example": "90210",
"masks": ["#####", "#####-####", "#####-######"]
}patternis matched against the compact postcode: upper case, spaces and hyphens removed. It is anchored with^and$.exampleis a real postcode as the country writes it. Theinvalidmessage shows it.masksplace 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, so941031234is written94103-1234.- The pattern must accept every example postcode libaddressinput gives for the country. Run
node scripts/postcode-examples.mjsafter 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 srcafter 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.
"format": {
"calling_code": "1",
"national_prefix": "1",
"masks": [{ "mask": "(###) ###-####" }],
"pattern": "^[0-9]{10,11}$",
"example": "(201) 555-0123"
}calling_code,national_prefixandexamplecome from libphonenumber;phone.pykeeps the calling code and example current. Thenational_prefixof a curated rule is the one its masks are written with, and is kept by hand.maskshas one entry per number shape, picked by astartregex; the last has none and is the default.- Keep
patternloose: 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) andexample, all written byscripts/phone.py; do not edit them by hand. test/fixtures/phone-parsing.jsontypes 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 holdsPOST /v1/validateto 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:
"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:
| Check | Description |
|---|---|
| Names | A misspelt key, field, token or label id |
| Layout | A field described but not laid out, or laid out twice |
| Fixed | A fixed field in the layout, or a bad fixed postcode |
| Format | A field left out, written twice, or not collected |
| Example | A missing required field, bad postcode or state |
| Postcode | An example or libaddressinput postcode refused |
| Phone | A libphonenumber example number refused |
| Wording | A label id missing from any locale |
| Generated | A bad currency or region code |
| Uncurated | A 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:
pnpm dev
curl 'localhost:8787/v1/countries/US?lang=en' | jqKeeping 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.