@sroussey/parse-address
Version:
International street address parser for 220 jurisdictions (US, CA + 218 countries and territories), with SEC EDGAR country-code resolution
477 lines • 20.5 kB
JavaScript
import { AddressParserUS } from "./maps/us/parser.js";
import { AddressParserCA } from "./maps/ca/parser.js";
import { AddressParserEU } from "./maps/_eu/parser.js";
import { euConfigs, euCountryCodes } from "./maps/_eu/registry.js";
import { stateCodesMap } from "./maps/us/states.js";
import { provinceCodesMap } from "./maps/ca/provinces.js";
import { enforceTokenPreservation, losesTokens } from "./invariant.js";
import { secCountryCodes } from "./maps/sec-countries.js";
import { stripLeadingOrganization } from "./preprocess.js";
const SUPPORTED_COUNTRIES = [
"us",
"ca",
...euCountryCodes,
];
/** True when we have a dedicated address grammar for this internal key. */
function isSupportedKey(key) {
// `hasOwnProperty`, not `Boolean(euConfigs[key])`: the latter is truthy for
// inherited Object.prototype members ("constructor", "__proto__", "toString"),
// which would then resolve as "supported" and blow up with an opaque TypeError.
return (key === "us" ||
key === "ca" ||
Object.prototype.hasOwnProperty.call(euConfigs, key));
}
/**
* Resolve a country selector to an internal parser key. Accepts our internal
* ISO alpha-2 keys ("us", "de", "ky", …) and SEC EDGAR "State or Country" codes
* ("K3" → Hong Kong, "L3" → Israel, …). A SEC code is resolved to its modern
* ISO country; an obsolete code with no successor, or a real country we do not
* have a grammar for yet, throws a descriptive error.
*/
export function resolveCountryKey(input) {
const lower = input.trim().toLowerCase();
if (isSupportedKey(lower))
return lower;
const sec = secCountryCodes[input.trim().toUpperCase()];
if (sec) {
if (!sec.iso2) {
throw new Error(`SEC code "${input}" (${sec.name}) is an obsolete jurisdiction with no modern country`);
}
const key = sec.iso2.toLowerCase();
if (isSupportedKey(key))
return key;
throw new Error(`SEC code "${input}" resolves to ${sec.name} (${sec.iso2}), which has no address grammar yet`);
}
throw new Error(`Unsupported country "${input}" (${SUPPORTED_COUNTRIES.length} supported ISO/SEC codes; pass a valid ISO alpha-2 or SEC "State or Country" code)`);
}
export { AddressParserImpl } from "./types/parser.js";
export class AddressParser {
// Accepts an internal ISO key ("us", "de", "ky") or a SEC EDGAR code ("K3").
constructor(country = "us") {
Object.defineProperty(this, "parser", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
const key = resolveCountryKey(country);
if (key === "us") {
this.parser = new AddressParserUS();
}
else if (key === "ca") {
this.parser = new AddressParserCA();
}
else {
this.parser = new AddressParserEU(euConfigs[key]);
}
}
normalizeAddress(parts) {
return this.parser.normalizeAddress(parts);
}
get ignored() {
var _a, _b;
return (_b = (_a = this.parser).droppableTokens) === null || _b === void 0 ? void 0 : _b.call(_a);
}
// Parse `address` as written first; strip a leading legal-entity / "c/o"
// segment (EDGAR "street1") ONLY when the address does not already parse
// losslessly. In street-first (number-after) layouts the leading segment IS
// the street, and words like "Capital"/"Group"/"Trust" collide with the
// org-suffix set -- an unconditional strip would delete the real street. A
// genuine EDGAR record loses tokens un-stripped, which triggers the strip.
// `ignored` is read immediately after each parse so it reflects that parse's
// dropped tokens (droppableTokens() is stateful).
run(parse, address) {
// A leading legal-entity / "c/o" segment is stripped ONLY when it is a real
// organization -- not when it is a street. In street-first (number-after)
// layouts the leading segment IS the street ("Avenida Capital, 12, ...") and
// its last word ("Capital"/"Group"/"Trust") collides with the org-suffix
// set; `looksLikeStreet` rejects those (they parse to a street type/number),
// while a genuine filer name ("Bank of Bermuda (Cayman) Limited") does not.
// The strip is tried FIRST for a genuine org, because the un-stripped parse
// of an EDGAR record is often "lossless" only by dumping the org name into
// `street`, which would otherwise fool the token guard.
const { cleaned, organization } = stripLeadingOrganization(address);
if (cleaned !== address &&
organization &&
!this.looksLikeStreet(parse, organization)) {
const stripped = parse(cleaned);
const strippedIgnored = this.ignored;
if (stripped && !losesTokens(cleaned, stripped, strippedIgnored)) {
// Surface the removed entity so no part of the input is silently lost.
stripped.organization = organization;
return stripped;
}
}
const asIs = parse(address);
const asIsIgnored = this.ignored;
return enforceTokenPreservation(address, asIs, asIsIgnored);
}
// A removed leading segment is a real STREET, not an organization, when it
// parses to a street type or a house number under this country's grammar
// ("Avenida Capital" -> type "Avenida"). Such a segment must never be stripped,
// even though its last word ("Capital", "Group", "Trust") is in the org-suffix
// set; a genuine filer name ("Cayman Islands Holdings Ltd") yields neither.
looksLikeStreet(parse, segment) {
const p = parse(segment);
return !!(p && (p.type || p.number));
}
parseAddress(address) {
return this.run((a) => this.parser.parseAddress(a), address);
}
parseStreet(address) {
return this.run((a) => this.parser.parseStreet(a), address);
}
parseInformalAddress(address) {
return this.run((a) => this.parser.parseInformalAddress(a), address);
}
parsePoAddress(address) {
return this.run((a) => this.parser.parsePoAddress(a), address);
}
parseLocation(address) {
return this.run((a) => this.parser.parseLocation(a), address);
}
parseIntersection(address) {
return this.parser.parseIntersection(address);
}
findStreetTypeShortCode(streetType) {
return this.parser.findStreetTypeShortCode(streetType);
}
droppableTokens() {
var _a, _b, _c;
return (_c = (_b = (_a = this.parser).droppableTokens) === null || _b === void 0 ? void 0 : _b.call(_a)) !== null && _c !== void 0 ? _c : [];
}
}
/**
* Check if address contains explicit country name indicators
*/
function hasExplicitCountryIndicators(address) {
if (/\b(Canada)\b/i.test(address)) {
return "ca";
}
if (/\b(US|USA|United States)\b/i.test(address)) {
return "us";
}
return null;
}
/**
* Check for postal code format patterns
*/
function detectCountryByPostalCode(address) {
// Canadian postal code pattern: A1A 1A1 or A1A1A1
const canadianPostalCode = /[A-Za-z]\d[A-Za-z]\s*\d[A-Za-z]\d/;
// US ZIP code pattern: 12345 or 12345-1234
const usZipCode = /\b\d{5}(?:-?\d{4})?\b/;
if (canadianPostalCode.test(address)) {
return "ca";
}
if (usZipCode.test(address)) {
return "us";
}
return null;
}
/**
* Strong European signals: an explicit country name, or a country-specific
* postcode shape distinctive enough not to collide with US ZIP / CA postal.
* The continental countries (DE/FR/IT/ES) all share a bare 5-digit postcode
* that is ambiguous with a US ZIP, so without an explicit country name they are
* left to the US default -- callers who know the country should pass it.
*/
function detectEuCountry(address) {
const names = [
["de", /\b(?:Deutschland|Germany)\b/i],
["fr", /\bFrance\b/i],
["it", /\b(?:Italia|Italy)\b/i],
["es", /\b(?:España|Espana|Spain)\b/i],
["nl", /\b(?:Nederland|Netherlands|Holland)\b/i],
["gb", /\b(?:United Kingdom|Great Britain)\b/i],
["be", /\b(?:België|Belgie|Belgique|Belgien|Belgium)\b/i],
// No leading \b: JS word boundaries are ASCII-only and fail before "Ö".
["at", /(?:Österreich|Oesterreich|Austria)\b/i],
["pl", /\b(?:Polska|Poland)\b/i],
["ch", /\b(?:Schweiz|Suisse|Svizzera|Switzerland)\b/i],
["pt", /\bPortugal\b/i],
["se", /\b(?:Sverige|Sweden)\b/i],
["dk", /\b(?:Danmark|Denmark)\b/i],
["no", /\b(?:Norge|Noreg|Norway)\b/i],
["fi", /\b(?:Suomi|Finland)\b/i],
["ie", /\b(?:Ireland|Éire|Eire)\b/i],
["cz", /\b(?:Česko|Česká republika|Czech Republic|Czechia)\b/i],
["gr", /(?:Ελλάδα|Ελλάς|\bGreece\b|\bHellas\b)/i],
// Offshore jurisdictions / Crown Dependencies. "Jersey" is guarded against
// the US state "New Jersey".
["ky", /\bCayman Islands\b/i],
["vg", /\b(?:British Virgin Islands|BVI|B\.V\.I\.)\b/i],
["bm", /\bBermuda\b/i],
["gi", /\bGibraltar\b/i],
["je", /(? {
var _a;
const usNames = new Set(Object.keys(stateCodesMap).map((s) => s.toLowerCase()));
const caNames = new Set(Object.keys(provinceCodesMap).map((s) => s.toLowerCase()));
const out = [];
for (const code of euCountryCodes) {
const cfg = euConfigs[code];
const names = ((_a = cfg.countryNames) !== null && _a !== void 0 ? _a : []).filter((n) => {
const l = n.toLowerCase();
return n.length >= 4 && !usNames.has(l) && !caNames.has(l);
});
if (!names.length)
continue;
const alt = names
.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+"))
.sort((a, b) => b.length - a.length)
.join("|");
out.push([code, new RegExp(`(?:^|\\s)(?:${alt})\\s*$`, "i")]);
}
return out;
})();
/**
* Detect a country from an explicit country name spelled out as the final
* segment of the address ("..., Melbourne VIC 3000, Australia" -> "au"). Every
* registry config already declares its `countryNames`; this consults them all,
* so a spelled-out country routes correctly for every grammar, not just the
* original two dozen.
*/
function detectCountryByName(address) {
var _a;
const lastSeg = (_a = address.split(",").pop()) === null || _a === void 0 ? void 0 : _a.trim();
if (!lastSeg)
return null;
for (const [code, re] of COUNTRY_NAME_MATCHERS) {
if (re.test(lastSeg))
return code;
}
return null;
}
/**
* Check for province or state names (full names)
*/
function detectCountryByRegionNames(address) {
const addressLower = address.toLowerCase();
const canadianProvinces = Object.keys(provinceCodesMap);
const usStates = Object.keys(stateCodesMap);
// Check for Canadian provinces (full names first)
for (const province of canadianProvinces) {
if (addressLower.includes(province.toLowerCase())) {
return "ca";
}
}
// Check for US states (full names)
for (const state of usStates) {
if (addressLower.includes(state.toLowerCase())) {
return "us";
}
}
return null;
}
/**
* Check for province or state codes (abbreviated forms)
*/
function detectCountryByRegionCodes(address) {
// SEC EDGAR region codes (A0-B0/Z4) are intentionally NOT used for country
// detection: they collide with unit numbers ("Apt A8"), so a US address with
// no ZIP/state would misroute to Canada. They are recognized only by the CA
// parser itself; a real EDGAR Canadian address auto-detects via its Canadian
// postal code, which detection matches by shape before region codes.
const canadianProvinceCodes = Object.values(provinceCodesMap);
const usStateCodes = Object.values(stateCodesMap);
// Check US state codes first (more common)
for (const stateCode of usStateCodes) {
if (new RegExp(`\\b${stateCode}\\b`).test(address)) {
return "us";
}
}
// Check for Canadian province codes (only if not already matched as US)
for (const provinceCode of canadianProvinceCodes) {
if (new RegExp(`\\b${provinceCode}\\b`).test(address)) {
return "ca";
}
}
return null;
}
/**
* Check for French street types (strong indicator of Canada)
*/
function detectCountryByFrenchStreetTypes(address) {
const addressLower = address.toLowerCase();
const frenchStreetTypes = ['rue', 'chemin', 'boulevard', 'avenue', 'allée'];
for (const frenchType of frenchStreetTypes) {
if (addressLower.includes(frenchType)) {
return "ca";
}
}
return null;
}
/**
* Resolve the country for an address using a fixed precedence (the documented
* contract; callers who know the country should pass it explicitly to
* IntlAddressParser instead of relying on detection):
* 1. Explicit country name/code in the text ("Canada", "US"/"USA")
* 2. Postal-code shape (Canadian FSA/LDU vs US ZIP)
* 3. Full region names (province/state)
* 4. Region codes (province/state abbreviations; US checked first)
* 5. French street types (strong Canada signal)
* 6. Default: US
*/
function detectCountry(address) {
// Check explicit country indicators first
const explicitCountry = hasExplicitCountryIndicators(address);
if (explicitCountry) {
return explicitCountry;
}
// Strong European signals (explicit name, or UK/NL postcode shape) before the
// US-ZIP default, which would otherwise claim continental 5-digit codes.
const euCountry = detectEuCountry(address);
if (euCountry) {
return euCountry;
}
// A country spelled out as the final segment routes to its grammar for the
// full registry (Australia, India, South Africa, UAE, Brasil, ...), not just
// the two dozen detectEuCountry hardcodes.
const namedCountry = detectCountryByName(address);
if (namedCountry) {
return namedCountry;
}
// Check postal code formats (more reliable than province/state codes)
const postalCodeCountry = detectCountryByPostalCode(address);
if (postalCodeCountry) {
return postalCodeCountry;
}
// Check for region names (provinces/states)
const regionNameCountry = detectCountryByRegionNames(address);
if (regionNameCountry) {
return regionNameCountry;
}
// Check for region codes (province/state abbreviations)
const regionCodeCountry = detectCountryByRegionCodes(address);
if (regionCodeCountry) {
return regionCodeCountry;
}
// Check for French street types
const frenchStreetCountry = detectCountryByFrenchStreetTypes(address);
if (frenchStreetCountry) {
return frenchStreetCountry;
}
// Default to US
return "us";
}
export class IntlAddressParser {
constructor() {
// Parsers are built on first use, not up front: eagerly constructing all ~220
// country parsers compiled every XRegExp grammar on instantiation (~700 ms)
// even when the caller only ever touches one country.
Object.defineProperty(this, "parsers", {
enumerable: true,
configurable: true,
writable: true,
value: {}
});
}
// country accepts an internal ISO key or a SEC EDGAR code ("K3"); omit to auto-detect.
pick(address, country) {
// Auto-detection runs on the org-stripped string so a leading legal-entity
// segment ("CAYMAN ISLANDS HOLDINGS LTD, 10 Downing Street, London ...")
// cannot hijack the country -- the parse itself already strips on demand.
const resolved = country
? resolveCountryKey(country)
: detectCountry(stripLeadingOrganization(address).cleaned);
let parser = this.parsers[resolved];
if (!parser) {
parser = new AddressParser(resolved);
this.parsers[resolved] = parser;
}
return parser;
}
parseLocation(address, country) {
return this.pick(address, country).parseLocation(address);
}
parseAddress(address, country) {
return this.pick(address, country).parseAddress(address);
}
parseInformalAddress(address, country) {
return this.pick(address, country).parseInformalAddress(address);
}
parseStreet(address, country) {
return this.pick(address, country).parseStreet(address);
}
parsePoAddress(address, country) {
return this.pick(address, country).parsePoAddress(address);
}
parseIntersection(address, country) {
return this.pick(address, country).parseIntersection(address);
}
}
/** Default US parser instance (convenience for `import addressParser from ...`). */
const addressParser = new AddressParser("us");
export default addressParser;
//# sourceMappingURL=parser.js.map