@classytic/contact
v0.2.0
Published
Contact-point identity kernel — canonical E.164 phones (metadata-driven, with the inverse local/carrier transform) and canonical emails. Region is injected, never defaulted; invalid input throws instead of degrading. Pure functions, zero I/O, below every
Readme
@classytic/contact
Contact-point identity. Free-form input → a canonical key you can dedup on, or an error. Phones (metadata-driven E.164, plus the inverse local/carrier transform) and emails.
Pure functions, no I/O, no mongoose, no arc — this package sits below every domain kernel. MIT · ESM only · Node ≥ 22.
npm i @classytic/contactWhy this exists
One rule had three implementations in this codebase — in @classytic/party,
in be-prod, and in hotel/server (75 lines with a hardcoded 'BD'). None of
them errored. They just disagreed, and the disagreement showed up as one gym
member existing as several party rows, each carrying its own entitlements, door
card and loyalty balance.
The fix is not "be careful". It is one implementation that refuses what it cannot canonicalise.
Two subpaths, and no root barrel
import { normalizePhone, toNationalDigits } from '@classytic/contact/phone';
import { normalizeEmail } from '@classytic/contact/email';There is no . export — import '@classytic/contact' throws
ERR_PACKAGE_PATH_NOT_EXPORTED, on purpose. phone costs ~158 KB of Google's
phone metadata; email costs no package at all. A root barrel, or a
normalizeContactPoint({ type, … }) dispatcher as the primary entry point, would
reference both implementations and make an email-only consumer ship the phone
metadata anyway. The cost would be invisible — nothing errors, the bundle is just
bigger than the import.
The two modules import nothing from each other, so this is structural rather than
a promise, and npm run check:dist walks the built graph to prove it:
email graph external deps: (none) [builtins: node:url]
phone graph external deps: libphonenumber-js/maxemail reaches no package — the gate asserts exactly that, which is stricter
than naming the one library it fears. node:url (IDNA) is a builtin: nothing to
install, nothing added to a package graph. It does mean email is Node-only;
that is the right trade for an identity kernel that already declares
engines.node >= 22, since getting IDNA wrong forks a mailbox into two parties.
If you have a { type, value } pair, branch on it at the call site — two lines,
and your bundle keeps the property.
@classytic/contact/phone
normalizePhone('01712345678', { defaultRegion: 'BD' }); // '+8801712345678'
normalizePhone('+8801712345678'); // '+8801712345678'
normalizePhone('٠١٧١٢٣٤٥٦٧٨', { defaultRegion: 'BD' }); // '+8801712345678'
normalizePhone('01712345678'); // throws PhoneFormatError
parsePhone('01712345678', { defaultRegion: 'BD' });
// { e164: '+8801712345678', callingCode: '880', nationalNumber: '1712345678',
// region: 'BD', type: 'MOBILE', extension: undefined, input: '01712345678' }
toNationalDigits('+8801712345678'); // '01712345678' ← Pathao / RedX
toNationalDigits('+390612345678'); // '0612345678' ← Italy keeps its 0
toNationalDigits('+14155550182'); // '4155550182' ← US has no trunk prefix
formatNational('+8801712345678'); // '01712-345678'
formatInternational('+8801712345678'); // '+880 1712 345678'
assertSupportedRegion(process.env.DEFAULT_PHONE_REGION ?? 'BD'); // throws on 'bd' / 'BGD'ParsedPhone extends PhoneNumber from @classytic/primitives/phone — the
value object is not re-declared here. type is the mobile-vs-landline answer an
SMS transport needs.
The inverse transform exists so the first courier integration does not hand-roll
"strip +880, prepend 0". Three countries, three different right answers, none
of them expressible as a string rule.
Two error types, because they have two different owners.
PhoneFormatError means the input is wrong (a 400 on the field);
PhoneRegionError means the deployment is wrong. libphonenumber-js reports
both as INVALID_COUNTRY, so without the split a misconfigured region presents to
support as "the till stopped taking phone numbers".
There is intentionally no PhoneError base class:
@classytic/primitives/phone already exports that name, and a second one would
let catch (e) { if (e instanceof PhoneError) } compile against the wrong import
and silently never match.
@classytic/contact/email
normalizeEmail(' [email protected] '); // '[email protected]'
normalizeEmail('[email protected]'); // '[email protected]' ← unchanged
normalizeEmail('ada@bücher.de'); // '[email protected]' ← IDNA, one key
normalizeEmail('ada@localhost'); // throws EmailFormatErrorUnicode domains fold to punycode. bücher.de and xn--bcher-kva.de are one
host, so keying them apart is this package's own bug in a second encoding. UTS-46
via domainToASCII from node:url — ICU-backed, spec-compliant, zero added
dependencies. The canonical identity is the ASCII form; domainToUnicode renders
a display form if one is wanted, but two spellings of one key is the whole bug.
The accepted grammar is RFC 5321 dot-atom — an "ordinary internet mailbox",
written out in the src/email.ts docblock and pinned case-by-case in the tests:
local = atext+ ("." atext+)* no leading/trailing/doubled dot
domain = label ("." label)+ ≥ 2 labels, LDH only, no leading/trailing
hyphen, no underscore, TLD not all-digitsQuoted local parts ("ada bob"@x.com), address literals (ada@[192.168.0.1]) and
non-ASCII local parts (SMTPUTF8) are out of scope and explicitly refused, not
accepted by accident. Each is legal somewhere in the RFCs and each would need its
own canonicalisation rule before it could safely be keyed.
Limits are octets, not characters (Buffer.byteLength), and the 63-octet label
limit is applied after IDNA conversion — punycode expands, so 58 × ü is a
legal 58-character label that becomes a 64-octet one once converted.
Conservative on purpose. Trim, and lowercase both sides. Gmail-dot and +tag
folding are not applied and are not an option here: [email protected] and
[email protected] are two different inboxes, and [email protected] is a real inbox
people use on purpose. Applying a provider's rule everywhere MERGES two people —
the phone bug in reverse, in the irreversible direction.
Local-part case is folded, and that is a decision. RFC 5321 §2.3.11 says the
local part is case-sensitive; practice is that every mainstream provider folds it,
and @classytic/party has always keyed on the fully-lowercased address. Switching
to case-preserving now would re-key every mixed-case identity already stored and
fork exactly the records this package exists to unify. A canonicalisation rule can
be tightened, never loosened, once it has written keys.
The four rules this package holds
1 · The region is injected. This package names no country.
No default, and there will not be one. A country literal in shared code is
invisible when wrong — the number still normalizes, to somebody else's number.
Unset ⇒ E.164 only; a national format is refused, never guessed at. The region
is a fallback for input with no country code, never an override: +14155550182
is never re-read through it, because +1 is ~20 countries.
2 · Invalid input throws. There is no lenient variant.
No normalizePhoneOrNull, no fallback to the raw string. The predecessor's
well-meant +${digits} degradation produced keys like phone:+01309000993 —
neither a national number nor E.164 — and forked identities without a single
error. input on the error preserves the caller's spelling for support; it is not
an answer.
3 · Extensions are refused by normalizePhone.
E.164 cannot represent one, so normalizing +880… ext. 42 would drop it and merge
every desk at a company into one identity. Use
parsePhone(…, { allowExtension: true }) and carry extension yourself.
normalizePhone pins the flag at runtime, not just in its type — the Omit<>
alone was decoration, and its own test failed until the pin existed.
4 · A typed object is not a trusted one. formatNational and friends accept a
ParsedPhone, and revalidate it. A ParsedPhone can be hand-built, JSON
round-tripped, or read from a row written before this package existed; reading
.e164 straight off it returned 01012345678 for an invalid number, which is the
module contradicting its own documented guarantee.
Metadata: max, and why the default entry was not good enough
libphonenumber-js ships three metadata sets. This package imports
libphonenumber-js/max.
| variant | size | isValid() | getType() |
|---|---|---|---|
| min (the default entry) | ~84 KB | length only, for most countries | no |
| mobile | ~97 KB | false for landlines | yes |
| max ← ours | ~158 KB | strict digit validation | yes |
Measured on 1.13.10, not inferred:
| input | min | max |
|---|---|---|
| +8801012345678 (BD prefix 010, unassigned) | valid | invalid |
| +8801212345678 (BD prefix 012, unassigned) | valid | invalid |
| +390612345678 (Rome landline) | valid | valid |
| +442071838750 (London landline) | valid | valid |
Bangladeshi mobile prefixes are 013–019. Under min the first two mint a stable
identity for a number nobody can dial — and the real customer's number never
resolves onto it. "Validity is isValid(), not isPossible()" is only a true
claim with max metadata.
mobile is disqualified for the opposite reason: it keeps only mobile patterns,
so isValid() is false for the Rome and London landlines above. A package that
silently refuses every landline is worse than one 60 KB larger.
isPossible() checks length only, which is why it is not the gate: a
right-length number with an unassigned prefix is "possible", and admitting it
creates a durable key for an unreachable number.
Maintenance obligation: metadata ships with releases
Google's phone metadata changes — new prefixes, new operators, reassignments.
libphonenumber-js releases roughly twice a month off an automated watcher on
Google's PhoneNumberMetadata.xml. So:
libphonenumber-jsisneverBundled in the build, so a metadata refresh is annpm updatein the host — never a release of this package.- Stale metadata makes
isValid()over-strict, i.e. it refuses a newly assigned prefix. That is the accepted failure direction: it fails loudly at the point of entry rather than admitting an identity nobody can dial. If a real number is being refused, update the library first.
Layering
@classytic/contact ← this package
├─ libphonenumber-js/max (phone subpath only)
└─ type-only: @classytic/primitives/phone (the zero-dep E.164 value object)@classytic/primitives is zero-runtime-dependency by design, and
src/identity/phone.ts rejects libphonenumber by name while pointing at the
right home: "deeper validation … is the host's call to layer on top." This
package is that layer, so it fulfils primitives/phone rather than
contradicting it. The dependency is import type only.
Consumers import this package directly. No kernel re-exports it — a re-export
shim is a second name for one rule, which is where the drift started. It is a
peer dependency of @classytic/party so npm cannot nest a second copy and
break instanceof across the boundary.
Testing
npm test # 70 pure unit tests, no Mongo, no fixtures
npm run typecheck
npm run check # biome ci
npm run build # tsdown, publint on the same pass
npm run check:dist # imports the REAL dist — class identity, tree-shaking, no root barreltsconfig.json includes tests/** on purpose — a package that type-checks only
src/** cannot enforce a type-level assertion parked in tests/. And
check:dist exists because the unit suite structurally cannot see build-level
faults: vitest resolves src/*.ts into one module graph, so a build that emits
duplicate classes (or inlines the metadata into the email entry) stays green in
tsc, in the tests, and in publint.
