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.
Summary
A class defined inside a
compilePackagesdependency that extends thebuilt-in
Errorfailsinstanceof Error, even though its prototype chainvisibly contains
Error. The same subclass written in application code works.The practical effect is that every framework which routes errors with
err instanceof Errorsilently drops these. In our case a Zod validationfailure inside a Hono handler returns an empty 500 with
content-type: text/plainand never reachesapp.onError— where Node returns a 400 with aJSON problem body.
Environment
d36a1af0c(2026-09-05), reports0.5.1520Repro
zodError instanceof z.ZodErrorzodError instanceof ErrorzodErrorprototype chainZodError → Error → ObjectZodError → Error → Objectnew Mine("x") instanceof Error(app code)new Error("y") instanceof ErrorThe chain contains
Errorandinstanceof Erroris still false, so theErrorthe compiled package closes over is not theErrorthe application'sinstanceoftests against.How it surfaces
Hono dispatches to
onErroronly for values it considers errors. Throwingfrom a handler, same build:
A thrown
ZodErrorbehaves exactly like a thrown string. Under Node all sixreach
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.ZodErroritself works —instanceof z.ZodErroris true — so an applicationthat checks the specific class first still behaves. The failure is only for code
that generalises over
Error, which is what frameworks do.