Where it runs
packages/duck-error has zero runtime dependencies and builds to both ESM (dist/index.js)
and CJS (dist/index.cjs) via tsdown. Nothing in the source touches Node built-ins (no fs,
no crypto, no process), so the same package runs unmodified in:
- Browser bundles (Vite, webpack, esbuild, Next.js client components)
- Edge runtimes (Cloudflare Workers, Vercel Edge Functions, Deno Deploy)
- React Native / Hermes
- Any server runtime (Node, Bun, Deno)
You construct and throw AppError in client-side form validation exactly the same way you would
in a server route handler.
// client-side, same kit
import { AppError, throwAppError } from './errors'
function validateEmail(value: string) {
if (!value.includes('@')) throwAppError('INVALID_EMAIL', { value })
}
The one real gotcha: instanceof does not survive JSON
A server's err.toJSON() output travels to the client as plain JSON, and a client-side fetch
response body has no prototype chain. You cannot instanceof AppError a deserialized error on
the client, because it was never actually constructed there; it's a plain object shaped like
{ ok: false, error: { code, status, ...meta } }.
// WRONG: body is plain JSON, never an AppError instance
const body = await res.json()
if (body instanceof AppError) { /* never true */ }
// RIGHT: switch on the code field directly
const body = await res.json()
if (!body.ok) {
switch (body.error.code) {
case 'RATE_LIMITED':
// body.error.retryAfter is present, but not type-narrowed.
// TypeScript can't connect a client-side literal string to the
// server's registry types across a network boundary.
showRetryToast(body.error.retryAfter)
break
case 'INVALID_EMAIL':
showFieldError(body.error.value)
break
}
}
If you want the narrowing back on the client, share the registry's Code/Meta types (not the
kit itself, since createErrorKit should still only run once, wherever the errors are actually
constructed) through whatever your project already uses to share types between client and
server, such as a shared types.ts module, a generated OpenAPI/tRPC client, and so on. This package
does not do that wiring for you; it only guarantees the shape on the wire is consistent enough
to make that wiring straightforward.
hasErrorCode checks 'code' in err, not instanceof. See
Type narrowing. The property check works identically whether err
came from instanceof-eligible same-process construction or from parsed JSON with the right
shape; only the class identity is what JSON serialization can't carry across.