idnumbers
    Preparing search index...

    Migrating to idnumbers v2.0.0

    This guide covers upgrading from 1.x to v2.0.0, the release that completes epic #127. v1.11.0 added @deprecated JSDoc for most of what is listed here, so on v1.11.0 your editor already flags the affected call sites. METADATA.name, AnyMetadata, and adaptMetadata were not tagged.

    Area v1.x v2.0.0 What to do Issue
    Packaging Single CJS build, no exports map Dual ESM/CJS build with an exports map; Node.js >= 22 baseline (CI 22.x/24.x); TS target ES2022+ Stop deep-importing idnumbers/dist/...; import from idnumbers (or the new subpaths below) #120
    Module contract Two METADATA dialects: class-based (parsable/checksum/regexp) and function-based (isParsable/hasChecksum/pattern) One canonical IdMetadata shape everywhere; FunctionBasedMetadata, AnyMetadata, and adaptMetadata (incl. the match-anything regexp: /./) are deleted; multi-format countries use the new createCompositeValidator Read parsable/checksum/regexp instead of isParsable/hasChecksum/pattern; use countryName/idType instead of name #121
    Entry points Only the batteries-included root idnumbers import Additive: idnumbers/countries/<iso3> per-country subpaths + an idnumbers/core entry (registry, no countries registered) and register() Root import keeps working unchanged; opt into subpaths only if you want tree-shaking #122 (decision: #115)
    Parse results parseIdInfo() returns any | null parseIdInfo() returns a discriminated { ok: true, info } | { ok: false, reason }, typed per country through a ParseResultMap; results include the resolved alpha-3 code Check ok, then read info or reason, instead of checking for null #123
    ValidationResult types extractedInfo?: any; ParsedInfo has a loose [key: string]: any index signature; IdMetadata.aliasOf: any | null extractedInfo is typed per country; ParsedInfo's index signature is unknown; aliasOf is IdNumberClass<object> | null Narrow ParsedInfo fields before use; no action for a literal country code #123
    Removals SUPPORTED_COUNTRIES array; IMetadata alias Both removed Use listSupportedCountries() and IdMetadata today — both already exist in v1.x #124

    The package now provides both ESM and CJS builds behind a proper package.json exports map, targets ES2022+, and raises the baseline to Node.js >= 22 (CI runs 22.x and 24.x).

    Concretely:

    • The exports map exposes exactly four entry points: . (the package root, import { validateNationalId } from 'idnumbers' / const { validateNationalId } = require('idnumbers')), ./core and ./countries/<iso3> (the tree-shakeable entry points, see New entry points (#122)), and ./package.json. Every other subpath, including deep imports of compiled output like idnumbers/dist/countries/twn, throws ERR_PACKAGE_PATH_NOT_EXPORTED instead of silently resolving. Use the root import, or idnumbers/countries/twn for one country.
    • engines.node is >=22, matching the Node.js versions CI actually tests (22.x and 24.x).
    • The import condition resolves to the ESM build (dist/esm/, compiled with module: es2022) with its own .d.ts declarations; the require condition resolves to the CJS build (dist/cjs/) with its own .d.ts declarations — each condition gets type declarations matched to its own module format.
    • Both builds target ES2022 output. Neither ships source maps or declaration maps — the previous maps pointed at src/ files that were never published, so they were never actually usable by consumers.
    • Dual-package hazard: if a single process both require()s and imports idnumbers, Node loads two separate module instances — including two separate country registries. Pick one style per process; don't mix require('idnumbers') and import 'idnumbers' for the same dependency.

    Every country module now carries the single canonical IdMetadata shape; the second "function-based" METADATA dialect and the adapter that translated it are gone.

    Renamed fields. The 26 modules that used the function-based dialect (ALB, ARE, ARG, AUT, BEL, BGR, CHE, CHL, CHN, COL, CZE, DNK, EGY, ESP, EST, FIN, GRC, HUN, IDN, IRL, ISL, ITA, KOR, LVA, MEX, POL), plus BGD's OLD_METADATA/NEW_METADATA, now use the canonical names:

    v1.x (function-based dialect) v2.0.0
    METADATA.isParsable parsable
    METADATA.hasChecksum checksum
    METADATA.pattern regexp
    METADATA.name (removed)
    // v1.x — function-based modules
    HUN.METADATA.isParsable;
    HUN.METADATA.hasChecksum;
    HUN.METADATA.pattern;

    // v2.0.0 — canonical names everywhere
    HUN.METADATA.parsable;
    HUN.METADATA.checksum;
    HUN.METADATA.regexp;
    • METADATA.name (e.g. 'Hungary Personal ID Number') is removed. It only ever combined the country and ID type, so use countryName and idType instead.
    • These modules also gain the remaining IdMetadata fields they lacked: aliasOf: null and deprecated: false. (BGD's OLD_METADATA keeps deprecated: true and gains countryName/idType.)
    • getCountryIdFormat() is unaffected: it already returned the canonical shape for every country. You can migrate before upgrading by reading getCountryIdFormat(code)!.metadata.parsable / .checksum / .regexp.

    Removed exports. FunctionBasedMetadata, AnyMetadata, and adaptMetadata are deleted, including adaptMetadata's fallback defaults and its match-anything regexp: /./. CountryModule now requires METADATA: IdMetadata, and createValidator() passes a module's METADATA through unchanged.

    getCountryIdFormat().metadata is a copy. In v1.x, class-based countries returned their registered METADATA object itself, so editing the result changed how that country's input was checked. v2.0.0 returns a copy each time (#181); read the module's own METADATA export if you need the registered object.

    New: createCompositeValidator(members, overrides?) builds one validator for a country that accepts several ID formats:

    • validate(id) is true when any member validates it;
    • parse(id) returns the first non-null member result, in member order (omitted when no member can parse);
    • no checksum (members use different algorithms);
    • METADATA starts from the first member, spans every member's minLength/maxLength, and has a regexp matching any member's shape. parsable is true when the composite has parse, and checksum only when every member's format carries one. overrides (e.g. countryName/idType) are applied last; an override set to undefined is ignored;
    • it throws when member regexps cannot be combined into one (different flags, or a backreference such as \1); pass a regexp override for those members.

    Bangladesh (BGD) and San Marino (SMR) are now built with it. Their registered metadata describes everything the composite accepts: BGD spans 13–17 digits (old + new formats) and SMR spans 7–9 characters (the 9-digit SSI + the SM##### COE).

    One metadata fix. In v1.x, BGD's registered regexp was the match-anything /./ (#160): its METADATA mixed both dialects, so the adapter discarded the real regexp. It now matches both BGD formats. getCountryIdFormat('BGD').metadata.regexp changes, and malformed BGD input (the wrong length or shape) now gets a specific reason (invalid_length / invalid_format) instead of the generic validation_failed. Input with the right shape but invalid content, such as 0163990150001, still gets validation_failed.

    Validation behavior itself does not change: the same IDs are accepted and parsed identically before and after, matching the Python idnumbers source of truth.

    New entry points (#122)

    Additive. Per the approved #115 decision (ADR 002), v2.0.0 adds tree-shakeable entry points without touching the existing root import:

    // Still works exactly as before — batteries included, all 85 countries registered.
    import { validateNationalId } from 'idnumbers';

    // New in v2.0.0 — opt-in: only the countries you register reach your bundle.
    import { register, validateNationalId } from 'idnumbers/core';
    import { country as twn } from 'idnumbers/countries/twn';

    register(twn);
    validateNationalId('TWN', id);
    • idnumbers/core is the full validation API (validateNationalId, parseIdInfo, getCountryIdFormat, listSupportedCountries, the registry, types) with no countries registered. Until you register a country, its codes report reason: 'unsupported_country'.
    • idnumbers/countries/<iso3> (lowercase alpha-3, e.g. idnumbers/countries/twn) exposes one country module: its validator types plus a country definition to pass to register(). Importing it registers nothing. Use the lowercase path exactly: on a case-insensitive file system (macOS, Windows), idnumbers/countries/TWN loads a second copy of the module whose country definition conflicts with the lowercase one, so registering both throws; on Linux it does not resolve.
    • register(...countries) is idempotent for the same definition, so it is safe to call from several modules, or alongside the root import. Registering a different validator under a taken key or alias throws.
    • Custom registries: IValidatorRegistry gains a required registerCountry(country) method, so a class that implements the interface itself must add one.
    • In a single-country bundle, core plus one country is about 1.9–5.4 KB min+gzip, versus about 38 KB for the root import. CI enforces the budgets (npm run size).
    • The root idnumbers entry and every country namespace (TWN, USA, …) also gain the country definitions, plus register and defineCountry; nothing is removed.
    • The root import shares its registry with idnumbers/core. Importing the root anywhere in an app registers every country for the whole app, so import only idnumbers/core and country subpaths to get the size benefit.

    parseIdInfo() no longer returns any | null: it returns a discriminated result that reuses the ValidationFailureReason enum from #117, so an unsupported country, an invalid ID, and a valid ID that cannot be parsed are told apart:

    // v1.x
    const info = parseIdInfo('HUN', idNumber); // any | null — can't tell why it's null

    // v2.0.0
    const result = parseIdInfo('HUN', idNumber);
    if (result.ok) {
    result.info.birthDate; // Date: typed as Hungary's parse result
    result.countryCode; // 'HUN', the resolved alpha-3 code
    } else {
    result.reason; // why nothing was parsed
    }

    reason is one of:

    Reason When
    unsupported_country The country code is not registered (errorMessage is also set)
    invalid_length, invalid_format, checksum_mismatch, invalid_birthdate (v2.1.0), validation_failed The ID is invalid: the same reason validateNationalId() reports
    not_parsable (new) The ID is valid, but the country has no parser or it found nothing
    • ok: true implies the ID is valid. The France (FRA) and Norway (NOR) parsers skip the check digits, so v1.x parseIdInfo() returned info for some IDs validateNationalId() rejects; v2.0.0 fails them with checksum_mismatch. The parsed data for valid IDs is unchanged.
    • Types per country. ParseResultMap maps each alpha-3 code to its parse result type (never for a country without a parser). parseIdInfo() and validateNationalId() resolve a literal country code through it, case-insensitively and through aliases: parseIdInfo('tw', id) is typed with Taiwan's result, and validateNationalId('CHN', id).extractedInfo with China's. For a string variable the type is ParsedInfo. CountryCode, CountryAliasMap, and ParsedInfoFor<C> are exported too.
    • ParsedInfo is now { [key: string]: unknown } instead of isValid: boolean plus an any index signature: narrow a field before using it. Every country's parse result type extends it.
    • IdMetadata.aliasOf is IdNumberClass<object> | null instead of any. Every built-in ID type sets null.
    • Generic registry types. IdNumberClass, CountryModule, CountryValidator, and CountryDefinition take the parse result type as a type parameter, defaulting to ParsedInfo; createValidator, createCompositeValidator, and defineCountry infer it. A custom country's parse result type must extend ParsedInfo.

    To migrate, replace null checks with the ok discriminant:

    // v1.x
    const info = parseIdInfo(code, id);
    if (info) show(info);

    // v2.0.0
    const result = parseIdInfo(code, id);
    if (result.ok) show(result.info);

    Preparing on v1.x: validateNationalId() already returns a typed reason (#117); check it before calling parseIdInfo(), whose v1.x result stays any | null.

    The two symbols deprecated in v1.11.0 are removed:

    Removed Replacement Available today?
    SUPPORTED_COUNTRIES listSupportedCountries() Yes
    IMetadata IdMetadata Yes

    Both replacements already exist in v1.x, so you can switch over before upgrading with zero functional change:

    // v1.x only (deprecated; removed in v2.0.0)
    import { SUPPORTED_COUNTRIES, IMetadata } from 'idnumbers';

    // v1.x and v2.0.0
    import { listSupportedCountries, IdMetadata } from 'idnumbers';
    const countries = listSupportedCountries();

    listSupportedCountries() returns a fresh array on every call, where SUPPORTED_COUNTRIES was a snapshot taken when the module loaded.

    Each country's accepted input formats — letter case, surrounding whitespace, and separators — are now documented in docs/INPUT_FORMATS.md (#124). This is documentation only, with zero acceptance changes: the same IDs validate before and after, and a test keeps the table in step with the validators.

    The following are marked @deprecated starting in v1.11.0 so IDEs show a strikethrough ahead of their v2.0.0 removal/change:

    • IMetadata — use IdMetadata instead (#124; removed in v2.0.0)
    • IdMetadata.aliasOf's any type — the field stays (#123; narrowed in v2.0.0 to IdNumberClass<object> | null, see Typed parse results)
    • The function-based METADATA dialect — isParsable, hasChecksum, pattern (renamed to parsable, checksum, regexp), and the FunctionBasedMetadata interface itself (#121; removed in v2.0.0)
    • SUPPORTED_COUNTRIES — use listSupportedCountries() instead (#118 deprecation; removed in v2.0.0 by #124)
    • The root idnumbers import remains batteries-included — no register() call needed unless you opt into idnumbers/core.
    • Which IDs are accepted does not change — v2.0.0 maintains parity with the Python idnumbers source of truth.
    • validateNationalId()'s arguments and result fields are unchanged (plus the optional reason field already added in #117); only the type of extractedInfo is narrowed (#123).
    • getCountryIdFormat()'s return shape is unchanged.