Skip to content

fix: make instanceof reliable for ApiError, TimeoutError and CanceledPromiseError - #52

Merged
stefanoverna merged 1 commit into
mainfrom
fix/error-identity
Aug 15, 2026
Merged

fix: make instanceof reliable for ApiError, TimeoutError and CanceledPromiseError#52
stefanoverna merged 1 commit into
mainfrom
fix/error-identity

Conversation

@stefanoverna

Copy link
Copy Markdown
Member

These packages ship parallel CJS and ESM builds, and none of them declared an exports map. A bundler could therefore resolve two distinct copies of the same module — and therefore two distinct ApiError classes — in which case instanceof ApiError silently returned false for an error thrown by the other copy:

bundled ApiError === handler ApiError: false
  e instanceof ApiError (bundled copy): true
  e instanceof ApiError (handler copy): false   ← the failure
  e.name                             : "Error"  ← no discriminator either

There was no fallback: the constructors never assigned name, so e.name was the inherited "Error" and could not be used to discriminate.

This fails silently — the guard compiles, type-narrows, and simply never matches, so an error handler looks correct while falling through to a 500.

Companion to datocms/cda-client#6, which fixes the same class of bug on the CDA client. The cause there was different (an ES5 downlevel that broke the prototype chain outright), but the cross-copy fix is the same.

Error identity

ApiError, TimeoutError and CanceledPromiseError now brand their instances with a symbol from the cross-realm global registry (Symbol.for('@datocms/rest-client-utils:<Name>')) and implement static [Symbol.hasInstance], making instanceof structural rather than identity-based. The registry is shared across module copies and realms, so the check holds wherever the error came from. Subclasses fall back to a real prototype-chain check so they stay exact.

Consumers need no code changes — plain instanceof just works now.

Each constructor also sets name ('ApiError', 'TimeoutError', 'CanceledPromiseError').

Verification

With two genuinely separate copies of the module loaded at once:

distinct ApiError classes : true
distinct TimeoutError     : true

  ApiError  across copies : true   ← was false
  Timeout   across copies : true   ← was false
  Canceled  across copies : true   ← was false

  ApiError not a Timeout  : true
  plain Error rejected    : true
  names                   : ApiError, TimeoutError, CanceledPromiseError

11 regression tests added in packages/rest-client-utils/src/__tests__/errors.test.ts, covering the prototype chain, cross-copy recognition, subclass exactness, cross-type confusion, and rejection of unrelated values (including an impostor error that merely sets name = 'ApiError').

Stack trace fix

TimeoutError's constructor called Error.captureStackTrace(this, ApiError) — the wrong constructor, so the stack was trimmed at the wrong frame. Now passes TimeoutError.

Packaging

Added exports maps to the seven packages that lacked one, using the map already present in cma-client-node as the template. Their ESM builds already use .js specifiers and already emit a {"type":"module"} marker, so they are genuinely loadable — the map just makes resolution explicit instead of leaving it to each bundler's mainFields ordering.

The maps deliberately keep ./dist/*, ./src/*, ./resources.json (where published) and ./package.json reachable, so existing deep imports keep working:

  dist/cjs/errors.js -> OK
  src/errors.ts      -> OK
  package.json       -> OK

One caveat: exports targets don't get Node's extension guessing, so a deep import must now spell out the extension (.../errors.js, not .../errors).

cma-client-analysis and cma-client-node already had maps and are untouched.

Testing notes

npx biome ci packages is clean and npm run build succeeds for all 9 packages. I ran the new errors.test.ts suite (11 passing) but not the full npm test, since the rest of the suite provisions and deletes real DatoCMS projects against a live account — worth a CI run before merging.

Release note

Worth shipping as a minor, not a patch: the exports maps change resolution.

…edPromiseError

These packages ship parallel CJS and ESM builds, and none of them declared an
`exports` map. A bundler could therefore resolve two distinct copies of the
same module — and therefore two distinct `ApiError` classes — in which case
`instanceof ApiError` silently returned `false` for an error thrown by the
other copy.

There was no fallback either: the constructors never assigned `name`, so
`e.name` was `"Error"` and could not be used to discriminate.

This fails *silently*: the guard compiles, type-narrows, and simply never
matches, so an error handler looks correct while falling through to a 500.

Error identity
------------------------------------------------------------------------
`ApiError`, `TimeoutError` and `CanceledPromiseError` now brand their
instances with a symbol from the cross-realm global registry
(`Symbol.for('@datocms/rest-client-utils:<Name>')`) and implement
`static [Symbol.hasInstance]`, making `instanceof` structural rather than
identity-based. The registry is shared across module copies and realms, so the
check holds wherever the error came from. Subclasses fall back to a real
prototype-chain check, so they stay exact.

Consumers need no code changes — plain `instanceof` just works now.

Each constructor also sets `name` ('ApiError', 'TimeoutError',
'CanceledPromiseError'), which was previously left as the inherited "Error".

Verified with two genuinely separate copies of the module loaded at once:
`instanceof` is now true across copies for all three classes, while a plain
`Error` — and an impostor that merely sets `name = 'ApiError'` — are still
correctly rejected.

Stack trace fix
------------------------------------------------------------------------
`TimeoutError`'s constructor called `Error.captureStackTrace(this, ApiError)`,
trimming the stack at the wrong constructor. Now passes `TimeoutError`.

Packaging
------------------------------------------------------------------------
Added `exports` maps to the seven packages that lacked one, using the map
already present in `cma-client-node` as the template. Their ESM builds already
use `.js` specifiers and already emit a `{"type":"module"}` marker, so they are
genuinely loadable — the map just makes resolution explicit instead of leaving
it to each bundler's `mainFields` ordering.

The maps deliberately keep `./dist/*`, `./src/*`, `./resources.json` (where
published) and `./package.json` reachable, so existing deep imports keep
working. One caveat: `exports` targets do not get Node's extension guessing, so
a deep import must now spell out the extension.

`cma-client-analysis` and `cma-client-node` already had maps and are untouched.
@stefanoverna
stefanoverna merged commit 5294fde into main Aug 15, 2026
3 checks passed
@stefanoverna
stefanoverna deleted the fix/error-identity branch August 15, 2026 15:14
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