npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/contact

Why 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 . exportimport '@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/max

email 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 EmailFormatError

Unicode 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-digits

Quoted 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-js is neverBundled in the build, so a metadata refresh is an npm update in 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 barrel

tsconfig.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.