Skip to main content

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.