Validate an address before you use it
Outcome. You have a function that rejects incomplete, malformed and weakly-read addresses, and you know which failures it cannot catch.
On this page, validation means three checks, run in this order. Every component you require must be present. Each component must have been read with high confidence. Each component must have a shape that the postal system allows. Mailwoman does not check deliverability. These checks use no carrier database and never send mail. Read What this does not tell you before you connect this function to a checkout form.
Prerequisites
- The library install from
Install and first parse, plus
@mailwoman/codexfor the postal reference tables. - You do not need a gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. database for the checks. Each check uses only the parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. and the reference tables, so none of them needs a data root. The last section adds a geocode. The geocode does need one.
1. Flatten the parse into components
classifier.parse() returns a tree, because a localitylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighborhood in the hierarchy. contains a streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. and a streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. contains a house
number. Validation checks presence and confidence and ignores nesting, so flatten the tree first:
function flatten(nodes, into = new Map()) {
for (const node of nodes) {
into.set(node.tag, { value: node.value, confidence: node.confidence })
flatten(node.children, into)
}
return into
}
Each node has a confidence in [0, 1], aggregated across the spanspanA contiguous range of characters or tokens in the input string, tagged with an address component type (street, locality, postcode, etc.). Parsed addresses are represented as collections of spans, possibly nested in a tree.'s tokenstokenOne word or subword in the tokenized input. For the neural classifier, tokens come from SentencePiece (subword units); for the rule classifiers, tokens are whitespace- and punctuation-separated words.. The tree has no
whole-parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. confidence field. It exposes only per-component scores, so you compute a row-level result
yourself. Base it on the weakest component.
2. Decide what "complete" means for your use
Completeness depends on what you will do with the address. A shipping labelcomponent tagOne of the 25 labels in Mailwoman's address schema — street, locality, region, postcode, house_number, unit, po_box, country, venue, intersection, and others. Each parsed span carries exactly one component tag. needs a house numberhouse numberThe numeric or alphanumeric identifier of a building on a street. Mailwoman's house_number component; its position relative to the street name flips between locales., and a sales-territory rollup does not. Write the list down explicitly:
/** Components a US mailing address needs before you send anything to it. */
const REQUIRED = ["house_number", "street", "locality", "region", "postcode"]
The component names come from the schema.
SCHEMA.mdx
has the full ComponentTag list, and Understand a parse
describes what each one holds.
3. Add the postal shape checks
@mailwoman/codex ships the postal reference tables as pure functions without runtime dependencies.
Three of them cover most of the errors a US address can have:
| Check | Function | Subpath |
|---|---|---|
| RegionregionThe first-level administrative subdivision of a country — a US state, a French region, a province. The component between country and locality. is a real state code | isUSStateAbbreviation(value) | @mailwoman/codex/us |
| PostcodepostcodeThe country-specific postcode (US ZIP, French code postal, etc.). Mailwoman handles postcode parsing entirely by rule classifier — a regex problem, not an ML one. has ZIP Code shape | isZipCode(value) | @mailwoman/codex/us |
| StreetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. ends in a USPS suffix | matchTrailingSuffix(name) | @mailwoman/codex/us |
Each of these checks whether a value could exist. None of them checks whether it does. isZipCode
is the regular expression ^\d{5}(?:[-\s]\d{4})?$, so 00000 passes and 2000 fails. Codex has no
per-ZIP registry by design. It is a zero-dependency reference package, and existence data is in the
gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture..
Other localeslocaleThe combination of language and country an address comes from. en-US and fr-FR are the locales Mailwoman ships weights for. ship the same pair of functions under their own subpath. Their names use the local term
instead of a generic one: isUkPostcode/normalizeUkPostcode under @mailwoman/codex/gb,
isCodePostal/normalizeCodePostal under /fr, isPostleitzahl/normalizePLZ under /de. Check the
subpath's exports instead of guessing the name.
candidateSystemsForPostcode(value) from the package root tests a string against every shape at once
and returns the address systems that accept it. Use it to check a postcodepostcodeThe country-specific postcode (US ZIP, French code postal, etc.). Mailwoman handles postcode parsing entirely by rule classifier — a regex problem, not an ML one. when you do not yet know the
countrycountryThe top-level address component (an ISO country). Closed-vocabulary, so it is best handled by a deterministic matcher feeding a proposal rather than a retrained model head..
4. Put it together
import { isStreetDirectionalToken, isUSStateAbbreviation, isZipCode, matchTrailingSuffix } from "@mailwoman/codex/us"
import { NeuralAddressClassifier } from "@mailwoman/neural"
const classifier = await NeuralAddressClassifier.loadFromWeights({ locale: "en-US" })
/** Components a US mailing address needs before you send anything to it. */
const REQUIRED = ["house_number", "street", "locality", "region", "postcode"]
/** The floor a component's confidence has to clear before it counts as read rather than guessed. */
const MIN_CONFIDENCE = 0.8
function flatten(nodes, into = new Map()) {
for (const node of nodes) {
into.set(node.tag, { value: node.value, confidence: node.confidence })
flatten(node.children, into)
}
return into
}
/**
* The USPS suffix of a street name, looking past a post-directional: `Pennsylvania Ave NW` ends in `NW`, so a bare
* trailing-token match misses the `Ave` that is the suffix.
*/
function trailingSuffixOf(streetName) {
const tokens = streetName.split(/\s+/)
while (tokens.length > 1 && isStreetDirectionalToken(tokens.at(-1))) tokens.pop()
return matchTrailingSuffix(tokens.join(" "))
}
export async function validateAddress(raw) {
const components = flatten((await classifier.parse(raw)).roots)
const problems = []
for (const tag of REQUIRED) {
if (!components.has(tag)) problems.push(`missing ${tag}`)
}
for (const [tag, c] of components) {
if (c.confidence < MIN_CONFIDENCE) problems.push(`low confidence on ${tag} (${c.confidence.toFixed(3)})`)
}
// Shape checks from the postal reference tables. Each one asks "could this exist", never "does this exist".
const region = components.get("region")
if (region && !isUSStateAbbreviation(region.value)) problems.push(`${region.value} is not a US state abbreviation`)
const postcode = components.get("postcode")
if (postcode && !isZipCode(postcode.value)) problems.push(`${postcode.value} is not ZIP Code shaped`)
const street = components.get("street")
if (street && !components.has("street_suffix") && !trailingSuffixOf(street.value)) {
problems.push(`${street.value} carries no recognized USPS street suffix`)
}
return { raw, ok: problems.length === 0, problems, components: Object.fromEntries(components) }
}
The directional strip in trailingSuffixOf is easy to miss. Pennsylvania Ave NW ends in NW, so a
direct trailing-suffix lookup returns no match and rejects a correct address. Post-directionals come
after the suffix, so strip them before the lookup.
Verify
node validate.mjs \
"1275 Pennsylvania Ave NW, Washington, DC 20004" \
"42 Nonexistent Ln, Zzyzx Falls, ZZ, 00000" \
"800 F St NW, Washington, DC 2000" \
"Suite 400" \
"1275 Pennsylvania Avenue Northwest, Washington"
ok 1275 Pennsylvania Ave NW, Washington, DC 20004
FAIL 42 Nonexistent Ln, Zzyzx Falls, ZZ, 00000
low confidence on region (0.462)
ZZ is not a US state abbreviation
FAIL 800 F St NW, Washington, DC 2000
2000 is not ZIP Code shaped
FAIL Suite 400
missing house_number
missing street
missing locality
missing region
missing postcode
low confidence on unit (0.038)
FAIL 1275 Pennsylvania Avenue Northwest, Washington
missing house_number
missing locality
low confidence on region (0.507)
low confidence on postcode (0.724)
Washington is not a US state abbreviation
1275 is not ZIP Code shaped
The last failure shows the limit of string checks. 1275 Pennsylvania Avenue Northwest, Washington has no state
and no ZIP Code, so the parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. assigned Washington to region at 0.507 and 1275 to postcode at
0.724. Both assignments are wrong, and both scored low. The confidence check and the shape check each
caught the same defect independently.
What this does not tell you
These two addresses pass every check above:
ok 99999 Pennsylvania Ave NW, Washington, DC 20004
ok 1275 Pennsylvania Ave NW, Chicago, IL 60606
There is no 99999 Pennsylvania Avenue, and 1275 Pennsylvania Avenue is in Washington. The second address places it in Chicago. Both addresses are complete, read with high confidence, and correctly shaped. Neither exists.
The checks cannot close this gap. Presence, confidence and shape are properties of the string.
Existence is a property of the world, and checking it requires a record to match against.
A geocode provides that record. resolution_tier on a geocode result identifies the finest record that
matched:
for a in "1275 Pennsylvania Ave NW, Washington, DC 20004" "99999 Pennsylvania Ave NW, Washington, DC 20004"; do
npx mailwoman geocode "$a" | jq -c '{resolution_tier, uncertainty_m, lat, lon}'
done
{"resolution_tier":"address_point","epistemic_status":"observed","uncertainty_m":1,"lat":38.89566505262116,"lon":-77.02925468482408}
{"resolution_tier":"admin","epistemic_status":"observed","uncertainty_m":null,"lat":38.904831,"lon":-77.016216}
That run had candidate.db plus the DC address-point database on disk (the setup
Improve geocode precision builds). Without that file, both
rows return admin and the check below cannot distinguish them. The precisionprecisionOf the spans the model labeled as a given tag, the fraction it got right. High precision means few false positives. Paired with recall to compute F1. data is what makes this
test meaningful.
address_point means a rooftoprooftopGeocoding precision at the building or parcel level — coordinates within a few meters — the highest tier of the geocode cascade. Sourced from address-point and situs data. record for that house numberhouse numberThe numeric or alphanumeric identifier of a building on a street. Mailwoman's house_number component; its position relative to the street name flips between locales. matched. admin means no level finer than
the citylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighborhood in the hierarchy. matched, and the returned coordinate is the city centroidlocality centroidThe representative center point of a city or locality, used as a coarse coordinate when no exact address point is available — the coarsest tier of the geocode cascade.. Require
result.resolution_tier === "address_point" in your check to test whether a rooftoprooftopGeocoding precision at the building or parcel level — coordinates within a few meters — the highest tier of the geocode cascade. Sourced from address-point and situs data. record matched,
rather than whether the address is merely well-formed. That is the closest this system comes to checking existence.
A matching record still does not establish deliverability. A rooftoprooftopGeocoding precision at the building or parcel level — coordinates within a few meters — the highest tier of the geocode cascade. Sourced from address-point and situs data. record states that a structure was surveyed at that address. It does not say that mail is accepted there, that the unitunitA subdivision of a building — apartment, suite, floor — that refines a street address. Mailwoman's unit component; a designator plus identifier forms a subpremise. number is right, or who the occupant is. No part of Mailwoman contacts a carrier. If you need to know whether a parcelparcelA property polygon or record carrying a situs (site) address and often a separate owner mailing address. County GIS parcel aggregations are a training source for address-point variety and situs-vs-owner divergence. will arrive, use a postal carrier's address-verification service.
Limits
- Shape checks are per-countrycountryThe top-level address component (an ISO country). Closed-vocabulary, so it is best handled by a deterministic matcher feeding a proposal rather than a retrained model head., and you must pick the countrycountryThe top-level address component (an ISO country). Closed-vocabulary, so it is best handled by a deterministic matcher feeding a proposal rather than a retrained model head..
isZipCoderejects a valid GB postcodepostcodeThe country-specific postcode (US ZIP, French code postal, etc.). Mailwoman handles postcode parsing entirely by rule classifier — a regex problem, not an ML one.. Choose the codex subpath from the parsed countrycountryThe top-level address component (an ISO country). Closed-vocabulary, so it is best handled by a deterministic matcher feeding a proposal rather than a retrained model head., or usecandidateSystemsForPostcodewhen the countrycountryThe top-level address component (an ISO country). Closed-vocabulary, so it is best handled by a deterministic matcher feeding a proposal rather than a retrained model head. is unknown. - Codex holds no existence tables. It has neither a ZIP-to-state cross-check nor a streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. registry.
The
ZipCodePrefixAbbreviationMapit ships maps a ZIP Code's first digit to a band of states. It is a coarse hint based on the ZIP Code's first digit. - A raw confidence near 0.9 does not mean 90% correct. The scores on this page are uncalibrated,
and their spread on clean input is narrow. Read
Tune confidence thresholds before you pick
MIN_CONFIDENCEfor production. The 0.8 used here is for demonstration and is not a recommendation. street_suffixis sometimes its own component and sometimes part ofstreet. The check above handles both cases, and any streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels.-shape check you write must handle both.
Related
- Handle messy input — normalizing before validating, and what the parser tolerates without help.
- Tune confidence thresholds — where to set
MIN_CONFIDENCE, and the tradeoff of each value. - Understand a parse — what the component tagscomponent tagOne of the 25 labels in Mailwoman's address schema — street, locality, region, postcode, house_number, unit, po_box, country, venue, intersection, and others. Each parsed span carries exactly one component tag. mean.