postal-code-checker
v2.3.0
Published
A package for validating postal codes from various countries.
Maintainers
Readme
📮 postal-code-checker
Validate postal codes and ZIP codes for 249 countries. TypeScript-first. Zero dependencies.
Prefer to try before installing?
Browse the full 249-country dataset, run single and batch validations, and edit live code right in the browser — no install required.
Powered by Google's libaddressinput — the same dataset behind Chromium, Android, and Google Pay address forms.
- Quick start
- Why postal-code-checker?
- Use cases
- API Reference
- Types
- Migration Guide
- Roadmap
- Data Sources
- Contributing
- License
⚡ Quick start
npm install postal-code-checkerimport { validatePostalCode, validatePostalCodes } from "postal-code-checker";
validatePostalCode("US", "90210"); // → true
validatePostalCode("CA", "k1a 0t6"); // → true (case + whitespace tolerant)
validatePostalCode("GBR", "SW1A 1AA"); // → true (alpha-3 works too)
validatePostalCodes("US", ["12345", "oops"]); // → [true, false]Works in React, Next.js, Vue, Svelte, Angular, Node.js, Deno, Bun, and plain browser JS — no framework assumptions.
💡 Why postal-code-checker?
Most postal-code libraries ship a hand-written list of regexes. Someone typed them in once, and they drift quietly as postal authorities change formats.
This one is generated, not typed. Every pattern is regenerated from Google's
first-party libaddressinput
Address Data Service — the dataset behind Chromium, Android, and Google Pay
address forms — and the release is blocked if the bundled data has drifted
from upstream. src/assets/index.ts carries a snapshot date, and
npm run sync:check fails the publish if it is stale. The data is not a
one-time import; it is a build output with a provenance trail.
That is what the rest of the feature list rests on:
- 🌍 249 countries — the full ISO 3166-1 alpha-2 and alpha-3 list, including the countries that have no postal system at all, where the correct answer is "this field does not apply" rather than a false rejection.
- 🔄 Regenerated per release, drift-gated — a stale dataset cannot ship.
- 📋 Batch API —
validatePostalCodes(country, codes[])returns an index-alignedboolean[]. Designed for CSV imports, bulk address uploads, and form arrays. - 🧩 Extensible at runtime —
configure()registers your own countries or overrides a bundled one. Codes outside ISO 3166-1 (Kosovo, internal region codes) work without forking the package. - 🔎 More than a boolean —
format()returns the canonical storable form,guessCountries()infers candidates from a bare code. - 🔤 Alpha-2 and alpha-3 both work — call with
"US"or"USA","GB"or"GBR". No branching at your call sites. - ✨ Forgiving input — case-insensitive and whitespace-tolerant.
"k1a 0t6"," K1A 0T6 ", and"K1A0T6"all validate equivalently where the country allows it. - 🪶 Zero dependencies, TypeScript-first, dual ESM + CommonJS — bundled
.d.ts, no@types/*install,importandrequire()both resolve through a strictexportsmap. - 🌐 Framework-agnostic — React, Next.js, Vue, Svelte, Angular, Node.js, Deno, Bun, plain browser JS.
🧩 Use cases
import { validatePostalCode } from "postal-code-checker";
function onZipBlur(country: string, zip: string) {
if (!validatePostalCode(country, zip)) {
return "Please check your postal code.";
}
// safe to POST to /address-lookup
}import { useForm } from "react-hook-form";
import { validatePostalCode } from "postal-code-checker";
const { register } = useForm<{ country: string; postal: string }>();
<input
{...register("postal", {
validate: (value, { country }) =>
validatePostalCode(country, value) || "Invalid postal code",
})}
/>import { validatePostalCodes } from "postal-code-checker";
const rows = await parseCsv("./addresses.csv");
const postals = rows.map((r) => r.postal);
const results = validatePostalCodes("US", postals);
const bad = rows.filter((_, i) => !results[i]);
console.log(`${bad.length} rows need review`);Batch calls avoid N regex compiles — one country lookup, N matches. Index-aligned output so you can zip results back to input rows.
"use server";
import { validatePostalCode } from "postal-code-checker";
export async function submitAddress(data: FormData) {
const country = String(data.get("country"));
const postal = String(data.get("postal"));
if (!validatePostalCode(country, postal)) {
return { error: "Invalid postal code for that country" };
}
// persist…
}import { getCountryByCode } from "postal-code-checker";
const country = getCountryByCode("DE");
// country.examplePostalCodes → ["10115"] ← use as placeholder
// country.postalCodePatterns → anchored regex strings, safe to render📚 API Reference
validatePostalCode(countryCode, postalCode): boolean
Validates a single postal code against a country. Accepts both alpha-2 and alpha-3 ISO 3166-1 codes. Input is trimmed and uppercased before matching, so "k1a 0t6", " K1A 0T6 ", and "K1A0T6" all validate equivalently where the country allows it.
validatePostalCodes(countryCode, postalCodes): boolean[]
Validates an array of postal codes against one country and returns an index-aligned array of results. Unique to this package — use it for CSV imports, bulk address uploads, and form arrays.
format(countryCode, postalCode): string | null — new in 2.1
Returns the canonical form of a valid postal code (trimmed, uppercased), ready to store in a database. Returns null when the input doesn't match the country's pattern or the country has no postal code system. Accepts alpha-2 and alpha-3.
format("CA", "k1a 0t6"); // → "K1A 0T6"
format("US", " 12345 "); // → "12345"
format("US", "ABC12"); // → nullguessCountries(postalCode): CountryOption[] — new in 2.1
Given a postal code with no country context, returns every country whose pattern accepts the input. Sorted alphabetically by countryName — ready to render as a picker. Countries with no postal code system are excluded.
guessCountries("K1A 0T6");
// → [{ countryName: "Canada", countryCode: "CA" }]
guessCountries("12345");
// → [{ countryName: "Algeria", ... }, { countryName: "Germany", ... }, ...]inferSubdivision(countryCode, postalCode): Subdivision[] — new in 2.3
Resolves a postal code to the state or province it belongs to — for auto-filling a region field from a ZIP, offline, with no API call. The postal code is validated first, so an invalid code never returns a wrong guess. Always an array sorted by code: subdivision prefixes overlap, so some codes genuinely belong to more than one region.
inferSubdivision("US", "90210"); // → [{ code: "CA", name: "California" }]
inferSubdivision("CA", "K1A 0T6"); // → [{ code: "ON", name: "Ontario" }, { code: "QC", name: "Quebec" }]
inferSubdivision("US", "999999"); // → [] (not a valid US ZIP)code is the ISO 3166-2 subdivision code without the country prefix ("CA", not "US-CA").
getSubdivisions(countryCode): Subdivision[] — new in 2.3
Every subdivision the dataset knows for a country, sorted by code — ready to render as a picker. [] when there is no data.
hasSubdivisionData(countryCode): boolean — new in 2.3
Whether subdivision data exists for a country. Use it to tell "no data for this country" apart from "the code matched nothing" — both make inferSubdivision return [].
isInSubdivision(countryCode, postalCode, subdivision): boolean — new in 2.3
Whether a postal code is valid and belongs to a specific subdivision (the unprefixed ISO 3166-2 code). Kept separate from validatePostalCode so consumers who only validate never bundle the subdivision dataset.
isInSubdivision("US", "90210", "CA"); // → true
isInSubdivision("US", "90210", "NY"); // → falsegetPostalLabel(countryCode): string — new in 2.3
The name a country uses for its postal code, for labelling a form field per country instead of showing "ZIP code" worldwide. Defaults to "postal code".
getPostalLabel("US"); // → "ZIP code"
getPostalLabel("IN"); // → "PIN code"
getPostalLabel("IE"); // → "Eircode"
getPostalLabel("DE"); // → "postal code"Subdivision coverage. The upstream dataset publishes subdivision postal prefixes for 24 of 249 countries — including the US, Canada, Brazil, India, Japan, Australia and Mexico, but not the UK, Germany, France or China.
inferSubdivisionreturns[]for the rest; usehasSubdivisionData()to tell that apart from a validation failure. Subdivision data is tree-shaken out of the default import, so consumers who only validate pay nothing for it.
getCountryByCode(countryCode): Country | null
Returns the full country record (patterns, example codes, name, 2-letter code) or null if unknown. Accepts alpha-2 or alpha-3. postalCodePatterns is a string[] — most countries have one entry, some (e.g. GB with BFPO) have several.
getAllCountries(): CountryOption[]
Returns { countryName, countryCode }[] for every supported country, sorted alphabetically.
configure(config): void — new in 2.1
Registers a user-supplied country dataset in one call, typically at app boot. Each entry either replaces a built-in country (when the alpha-2 key matches) or adds a brand-new country. Every utility in the package honors the override immediately — no call-site changes needed.
import { configure, validatePostalCode } from "postal-code-checker";
configure({
countries: {
// Kosovo — not in ISO 3166-1, so not in the bundled dataset
XK: {
patterns: ["/^(?:[1-7]\\d{4})$/"],
example: ["10000", "20000"],
country: "Kosovo",
alpha3: "XKX",
},
},
});
validatePostalCode("XK", "10000"); // → true
validatePostalCode("XKX", "10000"); // → true (alpha-3 works)Replace semantics per country, idempotent across calls, and a fail-fast ConfigurationError on bad input. Full guide: docs/CONFIGURATION.md.
SSR / multi-tenant:
configure()is a module-level singleton and isn't designed for per-request overrides. If you need that, open an issue describing your use case.
resetConfig(): void — new in 2.1
Discards any active configure() override and restores the bundled defaults. Primary use cases: test teardown (afterEach(resetConfig)), scenario switching, HMR.
afterEach(() => {
resetConfig();
});usePostalCodeValidation() — deprecated since 1.1.0
Retained for backward compatibility; delegates to the top-level validatePostalCode. Kept functional in 2.x; scheduled for removal in 3.0. Prefer the top-level exports in new code.
🏷️ Types
type CountryCode = string; // ISO 3166-1 alpha-2 ("US") or alpha-3 ("USA")
type AnyCountryCode = CountryCode | (string & {});
// preserves autocomplete for known codes while accepting runtime-added ones
type Country = {
postalCodePatterns: string[]; // regex strings wrapped in slashes, e.g. "/^\\d{5}$/"
examplePostalCodes: string[];
isGenericRegex: boolean;
countryName: string;
countryCode: AnyCountryCode;
};
// Shape accepted by configure()
type PostalCodeConfig = {
countries: {
[countryCode: string]: {
patterns: string[]; // each entry wrapped in slashes, e.g. "/^\\d{5}$/"
example: string[];
country: string;
alpha3?: string; // optional 3-letter uppercase alpha-3 code
};
};
};🔄 Migration Guide
Coming from a different postal-code library? See
docs/SWITCHING.md— covers the common one-line equivalents, batch API migration, and the argument-order gotcha that trips most swaps.
From v1.x → v2.0 (data-shape breaking change)
v2.0 switches to Google's libaddressinput dataset and renames the regex field on the Country record. The runtime API (validatePostalCode, validatePostalCodes, getCountryByCode, getAllCountries) is unchanged — but anything reading country.postalCodeRegex directly needs an update.
Before (v1.x):
const country = getCountryByCode("US");
const regex = new RegExp(country.postalCodeRegex.slice(1, -1));
regex.test("12345");After (v2.0):
const country = getCountryByCode("US");
const ok = country.postalCodePatterns.some((wrapped) =>
new RegExp(wrapped.slice(1, -1)).test("12345")
);If you were only calling validatePostalCode / validatePostalCodes, nothing changes — normalization and return types are identical.
Also note:
country.countryNamevalues follow Google's canonical spelling — e.g."United States"(was"United States of America"),"Russia"(was"Russian Federation"). Update any string pins in tests or UI copy.- A handful of previously-orphan alpha-3 codes (Åland, Martinique, Réunion, Puerto Rico, etc.) now resolve correctly through
getCountryByCode.
From usePostalCodeValidation (v1.0.x) → validatePostalCode (v1.1.0+)
// Before
import { usePostalCodeValidation } from "postal-code-checker";
const { validatePostalCode } = usePostalCodeValidation();
// After
import { validatePostalCode } from "postal-code-checker";The usePostalCodeValidation name followed React's hook naming convention, which confused non-React users and tripped the react-hooks/rules-of-hooks lint rule even though it's not an actual hook. The new direct API works identically in React, Node.js, Vue, Angular, Svelte, or plain JavaScript.
usePostalCodeValidation still exists in 2.x and delegates to the new implementation. Scheduled for removal in 3.0.
🗺️ Roadmap
✅ Shipped
inferSubdivision()+getSubdivisions()+hasSubdivisionData()+isInSubdivision()— resolve a postal code to its state/province from Google'ssub_zipsprefixes (24 countries) (2.3.0)getPostalLabel()— the name a country uses for its postal code, for per-country form labels (2.3.0)exportsmap + native.mjsESM build;NOTICEdata license corrected to CC-BY 4.0 (2.2.0)configure()+resetConfig()— user-supplied country overrides and brand-new countries via a single-place config (2.1.0)format()— canonical storable form, ornullif invalid (2.1.0)guessCountries()— countries whose pattern accepts an input (2.1.0)- Swap data source to Google
libaddressinput(2.0.0) - Reproducible data pipeline —
sync:data+sync:checkguard against upstream drift (2.0.0) postalCodePatterns: string[]— support countries with multiple valid patterns (2.0.0)- Interactive demo site at sashiksu.github.io/postal-code-checker (2.0.0)
- Full ISO 3166-1 coverage (249 countries, zero generic fallbacks) (2.0.0)
- Batch validation (
validatePostalCodes) (1.1.0) - Case + whitespace tolerant input (1.1.0)
- ISO 3166-1 alpha-3 support (1.1.0)
- Full unit-test coverage of utility functions (1.1.0)
🔜 Planned
createValidator()factory for SSR / multi-tenant use cases (if demand shows up)- Removal of deprecated
usePostalCodeValidation(3.0.0)
📊 Data Sources
Postal code patterns, country names, example codes, and subdivision prefixes come from Google's libaddressinput project, fetched from https://chromium-i18n.appspot.com/ssl-aggregate-address/data/<CC>. The same dataset powers address forms in Chromium, Android, and Google Pay. libaddressinput licenses its source code under Apache-2.0 and its data under CC-BY 4.0; only the data is used here, so CC-BY 4.0 applies.
scripts/sync-postal-data.ts regenerates src/assets/index.ts from upstream; npm run sync:check runs in CI and in prepublishOnly to block releases whose on-disk data has drifted from the script's output.
See NOTICE for the upstream CC-BY 4.0 data attribution.
Prior to v2.0.0, data was sourced from the European Central Bank (ECB), retrieved 4 Aug 2024.
🤝 Contributing
Pull requests welcome. See CONTRIBUTING.md for local setup, the country-data workflow (upstream-first via libaddressinput), and maintainer sync steps.
Quick start:
- Branch from
master:git checkout -b feature/<short-desc>orbugfix/<short-desc>. - Add or update tests under
src/__tests__/for any behavior change. - Follow existing style —
npm run prettierandnpm run lintship configs. - Open a PR targeting
master.
Found a bug, missing country, or want to propose a feature? Use the structured issue templates. For security issues, see SECURITY.md.
