Skip to content

dual ESM and CJS, with types first in every export condition - #4

Merged
tamnd merged 1 commit into
mainfrom
dual-format
Aug 18, 2026
Merged

tamnd merged 1 commit into
mainfrom
dual-format

Conversation

@tamnd

@tamnd tamnd commented Aug 18, 2026

Copy link
Copy Markdown
Owner

The package had one file, index.js, generated by napi, and one index.d.ts beside it. That is a CommonJS package that an ESM caller reaches through Node's interop, and a package whose types are read the same way for both formats whether or not that is true. This splits the two apart.

index.js and index.d.ts are now binding.cjs and binding.d.cts, which is what napi generates and nothing else. Over them sit four files that are the package: zudb.cjs requires the binding and names every export one at a time, zudb.mjs reaches those same objects through createRequire and re-exports them, zudb.d.cts says the shape plus the AsyncDisposable augmentation that cannot be spelled in a napi attribute, and zudb.d.mts points at it rather than writing the API twice.

One load underneath both, so a ZuDate made through import is an instance of the ZuDate reached through require, which is what a program with a mixed dependency tree needs and what two separate loads would quietly break. There is no default export in either format: a default beside the named exports is a second spelling of every name, and which one a caller gets depends on their bundler and their esModuleInterop.

types is the first key in both conditions. Conditions match in the order they are written, so a types after default is a types nothing ever reaches, and the package would compile here and be any everywhere else.

isZuError is new on the surface. A catch clause holds an unknown, and until now there was no way to ask whether the thing caught was a zu failure without reading the name off it and hoping. It answers no for a plain Error, for a string, for null and for an AbortError, which is the one a caller most needs kept separate from the database's own conditions, and in TypeScript it narrows to the full error shape. The test helper now uses the exported guard, so the suite exercises the predicate a caller would write.

Three things check the package as a resolver sees it rather than as this checkout sees it, which is the difference that only shows up after publishing:

  • npm run check:types compiles a .mts and a .cts program against the published shape, both under node16 resolution with strict, exactOptionalPropertyTypes and noUncheckedIndexedAccess. The tests themselves now import 'zudb' rather than a relative path, so they resolve through the package's own exports map.
  • npm run check:package runs attw --pack ., which reports no problems for node10, node16 CJS, node16 ESM and bundler.
  • test/exports.test.mjs writes out the eleven exported names rather than deriving them from either format, and asserts both formats give the same names and the same objects, that neither has a default, and that every condition names its types first.

Both are a CI job. tools/install.mjs now runs a statement through require('zudb') as well as through import, and compares a class across the two inside the installed project. The release workflow passes the two file names to napi build, since a build left on napi's defaults would publish a loader that is not the one that was built.

52 tests pass, attw is clean on all four resolution modes, tsc is clean, and the pack and install round trip runs a statement from both formats.

Part of tamnd/zu#169.

The binding napi generates is now binding.cjs and binding.d.cts, and
the package is what sits over it: zudb.cjs requires the binding and
names every export one at a time, zudb.mjs re-exports those same
objects through createRequire, and the two declaration files say the
same shape to a resolver reading .d.cts for require and .d.mts for
import. One load, so a ZuDate from either format is an instance of the
other's class. No default export in either, because a default beside
the named exports is a second spelling of every name whose meaning
depends on the caller's bundler.

types is the first key in both conditions, since conditions match in
the order they are written and a types after default is a types
nothing reaches.

isZuError joins the surface, because a catch clause holds an unknown
and there was no way to ask whether it was a zu failure without
reading the name off it.

npm run check:types compiles a program in each format against the
published shape, npm run check:package runs attw over a real npm pack,
and both are a CI job. The install round trip now requires and imports
the packed package rather than only importing it.
@tamnd
tamnd merged commit d789e7b into main Aug 18, 2026
17 checks passed
@tamnd
tamnd deleted the dual-format branch August 18, 2026 13:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant