Skip to content

A class extending Error inside a compilePackages dependency fails instanceof Error — Hono's onError never sees a ZodError #9940

Description

@proggeramlug

Summary

A class defined inside a compilePackages dependency that extends the
built-in Error fails instanceof Error, even though its prototype chain
visibly contains Error. The same subclass written in application code works.

The practical effect is that every framework which routes errors with
err instanceof Error silently drops these. In our case a Zod validation
failure inside a Hono handler returns an empty 500 with content-type: text/plain and never reaches app.onError — where Node returns a 400 with a
JSON problem body.

Environment

Perry d36a1af0c (2026-09-05), reports 0.5.1520
Platform Linux x86_64
zod 4.x · hono 4.13.4 · @hono/node-server 1.19.17

Repro

import { z } from "zod";
class Mine extends Error {}

try { z.object({ n: z.number() }).parse({ n: "no" }); } catch (e: any) {
  console.log("instanceof z.ZodError =", e instanceof z.ZodError);
  console.log("instanceof Error      =", e instanceof Error);
  console.log("proto chain           =", /* walk */);
}
console.log("own subclass          =", new Mine("x") instanceof Error);
console.log("plain Error           =", new Error("y") instanceof Error);
node perry
zodError instanceof z.ZodError true true
zodError instanceof Error true false
zodError prototype chain ZodError → Error → Object ZodError → Error → Object
new Mine("x") instanceof Error (app code) true true
new Error("y") instanceof Error true true

The chain contains Error and instanceof Error is still false, so the
Error the compiled package closes over is not the Error the application's
instanceof tests against.

How it surfaces

Hono dispatches to onError only for values it considers errors. Throwing
from a handler, same build:

plain        status=500 type=application/json  body={"handled":"Error"}       ← onError ran
subclass     status=500 type=application/json  body={"handled":"AppProblem"}  ← onError ran
async-plain  status=500 type=application/json  body={"handled":"Error"}       ← onError ran
string       status=500 type=text/plain        body=""                        ← onError skipped
zod          status=500 type=text/plain        body=""                        ← onError skipped
async-zod    status=500 type=text/plain        body=""                        ← onError skipped

A thrown ZodError behaves exactly like a thrown string. Under Node all six
reach onError.

Impact

Every request whose body or query fails validation becomes an opaque 500 with
an empty body and nothing logged — the error never reaches the handler that
would have logged it. From the client's side a typo in a request is
indistinguishable from a server fault, and from the server's side it is
invisible.

That is the whole public API surface of an app that validates with Zod. It cost
us an afternoon on a bid endpoint that was returning 500 for what turned out to
be a malformed clientRef.

Note

z.ZodError itself works — instanceof z.ZodError is true — so an application
that checks the specific class first still behaves. The failure is only for code
that generalises over Error, which is what frameworks do.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions