tldkit
v1.0.2
Published
A zero-dependency, ESM-first drop-in replacement for tldts and psl. Same API, same Public Suffix List, a fraction of the install size.
Maintainers
Readme
tldkit
A drop-in replacement for tldts and
psl — same API, same Public Suffix List,
a fraction of the install size.
npm install tldkit- import { getDomain, parse } from "tldts";
+ import { getDomain, parse } from "tldkit";That is the entire migration. Every export keeps its name, its signature and its
option defaults — allowPrivateDomains, validHosts, mixedInputs,
detectSpecialUse, and the full IResult field set. If you use psl instead,
the equivalent import is tldkit/psl.
Why
One reason, and it is install size.
tldts unpacks to 3,187 KB across tldts + tldts-core. Measured against that
tree: 1,809 KB of it is sourcemaps — 33 files — and the Public Suffix List trie
ships several times over, as TypeScript source (114 KB), as a .d.ts (70 KB),
once per module format under dist/es6 and dist/cjs (135 KB each), and inlined
again into three prebuilt bundles (index.umd.min.js, index.esm.min.js,
index.cjs.min.js). The list is not what makes the package big; the packaging
is.
tldkit ships the same list, encoded once, stored once, with no sourcemaps and
no prebuilt bundles.
Install size — bytes on disk after npm install
The whole transitive tree, which is what a CI cache, a Docker layer, a Lambda
bundle or a laptop actually holds. Reproduce with npm run size.
| package | transitive tree | on disk |
| --- | --- | --- |
| tldts | tldts + tldts-core | 3,186.9 KB |
| psl | psl + punycode | 728.0 KB |
| tldkit | tldkit | 140.9 KB |
22.6× smaller than tldts, 5.2× smaller than psl, and zero runtime
dependencies either way. Warm lookup throughput is at parity with tldts, so
this is not a size-for-speed trade — see What this is not
for the axes where it does cost you.
An earlier draft of this package targeted "under 100 KB". It is not reachable and the target was dropped rather than fudged: the encoded Public Suffix List is 69 KB on its own, and the rest is the dual ESM/CJS output and its type declarations. Dropping the CommonJS tree would get there, at the cost of every CommonJS consumer. 22.6× is measured; there is no rounder number behind it.
Bundled size — what a browser downloads
A separate claim, and a much smaller one. esbuild, bundled, minified,
tree-shaken, then gzipped. Reproduce with npm run size.
| import shape | baseline | gzip | tldkit entry | gzip | delta |
| --- | --- | --- | --- | --- | --- |
| named getDomain | tldts | 44.6 KB | tldkit | 41.6 KB | −3.0 KB |
| named parse | tldts | 44.6 KB | tldkit | 41.6 KB | −3.0 KB |
| whole namespace | tldts | 44.8 KB | tldkit | 41.7 KB | −3.2 KB |
| named getDomain | tldts | 44.6 KB | tldkit/icann | 28.5 KB | −16.2 KB |
| parse (psl API) | psl | 43.7 KB | tldkit/psl | 40.7 KB | −3.0 KB |
On the full dataset that is about 7% smaller — real, but thin, and not a reason on its own to change a dependency. Both libraries have to ship the same Public Suffix List, and the list is most of the bytes.
That margin is deliberately narrower than it once was. The dataset is stored in a denser encoding than it used to be, which is what shrank the install tree and the resident heap below — but denser bytes have higher entropy, so they gzip less well. The trade was taken knowingly: install size and memory are where this package wins, and the bundled column is where it pays for them.
The large bundled win needs the tldkit/icann entry point: 36% smaller,
44.6 KB down to 28.5 KB, because a bundler following that entry never reaches
the PRIVATE section of the list. tldts cannot offer the same thing — its trie
is one monolithic module covering both sections. The tradeoff is that
tldkit/icann cannot resolve private suffixes at all, so it is only the right
choice if you never pass allowPrivateDomains: true. npm run size probes the
emitted bundle for a PRIVATE-only rule and fails if one leaks in, so that row is
checked rather than asserted.
Runtime memory — heap once the list is loaded
A third claim, kept separate from the other two like they are from each other.
Both libraries hold the whole Public Suffix List resident and both keep it in
typed arrays; what differs is what that costs. tldkit expands a compact
encoded string into a flat radix trie — Uint8Array and Uint16Array edge,
flag and offset tables over a single run of label text — building only the
sections your options actually enable, and filling in its per-node lookup index
lazily as hostnames arrive rather than all at once.
Measured as heapUsed after parsing the whole benchmark corpus, against a
baseline taken before the import, under --expose-gc so both readings follow a
collection. Reproduce with npm run bench.
| | tldts | tldkit | |
| --- | --- | --- | --- |
| default, ICANN only | 1,140 KB | 645 KB | 0.57× |
| with the private section | 1,150 KB | 683 KB | 0.59× |
| tldkit/icann | 1,140 KB | 544 KB | 0.48× |
Roughly 500 KB less resident per process on the default path. That matters most where processes are many and short — a serverless worker per request, a test runner forking per file, a crawler with a pool of workers.
Entry points
| entry | dataset | API |
| --- | --- | --- |
| tldkit | ICANN + PRIVATE | tldts |
| tldkit/icann | ICANN only | tldts |
| tldkit/psl | ICANN + PRIVATE | psl |
tldkit — the default. Use it to replace tldts. Exports parse,
getHostname, getPublicSuffix, getDomain, getFullDomain, getSubdomain
and getDomainWithoutSuffix, plus a default export carrying all seven so
import tldts from "tldkit" works. Types IResult and IOptions are exported
too. allowPrivateDomains defaults to false, as in tldts.
tldkit/icann — the same API restricted to the ICANN section.
allowPrivateDomains is accepted and ignored. Worth using when you are
bundling for a browser and never need private suffixes; not worth using
anywhere the install-size argument is the one that matters, since both entries
ship in the same package.
tldkit/psl — the psl API: parse, get, isValid, errorCodes, and
a default export. It follows psl's own algorithm rather than the
tldts-compatible one, because the two genuinely disagree — on unlisted TLDs,
on .local, and on how much of a hostname counts as the subdomain. Matching
psl is the point of the entry point.
import { getDomain } from "tldkit";
import { getDomain as icannOnly } from "tldkit/icann";
import psl from "tldkit/psl";
getDomain("https://www.bbc.co.uk/news"); // "bbc.co.uk"
getDomain("foo.blogspot.com", { allowPrivateDomains: true }); // "foo.blogspot.com"
icannOnly("foo.blogspot.com"); // "blogspot.com"
psl.get("www.example.co.uk"); // "example.co.uk"Both ESM and CommonJS are shipped, with types for each, so require("tldkit")
works as well.
Migration
From tldts
npm install tldkit && npm uninstall tldts- import { getDomain } from "tldts";
+ import { getDomain } from "tldkit";- const { getDomain } = require("tldts");
+ const { getDomain } = require("tldkit");From psl
- import psl from "psl";
+ import psl from "tldkit/psl";Without changing any code
If the import sites are in a dependency you do not control, or there are too many of them to touch, alias the specifier at build time:
// vite.config.js
export default { resolve: { alias: { tldts: "tldkit", psl: "tldkit/psl" } } };// webpack.config.js
module.exports = { resolve: { alias: { tldts: "tldkit", psl: "tldkit/psl" } } };// esbuild
await build({ alias: { tldts: "tldkit", psl: "tldkit/psl" } });To reach a dependency's own import "tldts" rather than your bundle, redirect
it at install time instead — npm overrides, yarn resolutions, pnpm
pnpm.overrides:
{ "overrides": { "tldts": "npm:tldkit@^1" } }That route works for tldts only. psl's replacement lives at the
tldkit/psl subpath, and a package alias cannot point at a subpath.
An alias is worth verifying rather than assuming: it silently does nothing if the specifier is resolved through a path your configuration does not cover.
Correctness
The official publicsuffix.org conformance suite is vendored at
test/fixtures/psl-tests.txt and runs with allowPrivateDomains: true:
tldkit 76/78
tldts 76/78The two failures are .example.com and .example.example — a hostname with a
single leading dot, which the suite says must yield null and which both
libraries resolve as though the dot were absent. tldts fails the identical
two cases with the identical values. For a drop-in that is the correct
outcome: match the incumbent, and say where the incumbent deviates.
Beyond the suite, tldkit and tldts are run side by side over every rule in
the list — each with subdomain, uppercase and trailing-dot mutations, under both
allowPrivateDomains settings — and every field of the returned result must
match, not only domain. The two diverge on a small number of nested-wildcard
rules; the differential suite pins every one of them and asserts which behaviour
the publicsuffix.org algorithm calls for, so a divergence that is merely
different — rather than justified — fails the build.
The dataset itself is generated by scripts/build-data.mjs straight from
publicsuffix.org, committed rather than downloaded at install time, and
checked in CI: npm run check:data regenerates it and fails if what is
committed differs. The current snapshot is dated 2026-08-05 and carries
10,239 rules (6,949 ICANN, 3,290 private).
What this is not
It is not faster than tldts, and this README will not claim that it is.
Warm throughput lands within a few percent of tldts, on the low side of it as
often as the high, while the spread across the benchmark's own reps is 17–40% —
an order of magnitude wider than the difference. The supportable claim is
parity; anything finer is noise, and the benchmark prints that spread as a
column so you can check the reading. Node 26, [email protected], a fixed corpus of
140 hostnames and URLs; cold start is the median of 15 fresh processes,
throughput the median of 45 reps. Reproduce with npm run bench.
| | tldts | tldkit | |
| --- | --- | --- | --- |
| getDomain, warm | 6.8 M/s | 6.7 M/s | parity (0.99×) |
| parse, warm | 8.3 M/s | 8.2 M/s | parity (0.98×) |
| getDomain, warm, private section | 5.6 M/s | 5.4 M/s | parity (0.96×) |
| parse, warm, private section | 6.7 M/s | 6.4 M/s | parity (0.94×) |
tldkit expands its packed dataset on the first lookup rather than at module
evaluation, so the first call costs more than the ones after it. npm run bench
reports that alongside everything else.
The dataset is a snapshot, not a live fetch. The Public Suffix List changes continuously; this package pins the copy it was built with. A scheduled CI job regenerates it weekly, runs the full suite and opens a pull request when the list has moved, so the lag is a release cycle rather than an install. Nothing runs on install, and nothing reaches the network at runtime. If you need the list as of this minute, no npm package will give you that.
tldkit/psl covers psl's core API, not every corner of it. parse,
get, isValid and errorCodes are there and are differentially tested
against psl. If you depend on something outside that set, check before you
switch.
It is not a validator. getDomain returning null means "no registrable
domain under the Public Suffix List", which is not the same question as "is this
a hostname I should accept".
Requirements
Node 18 or newer. No Node built-ins are used at runtime, so it works in browsers and edge runtimes. Zero runtime dependencies.
Security
Nothing executes on install and no network access happens at any point — the Public Suffix List is committed, not fetched. Hostname validation is character-class scanning rather than a regex, so there is no backtracking behaviour to trigger on hostile input.
See SECURITY.md for the full posture and threat model.
License
MIT © Damin3927
tldts and psl are separate projects, used here as devDependencies for
differential testing only. tldkit is an independent implementation and is not
affiliated with or endorsed by either.
