fix(core-shared): match domain errors by name across module graphs
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
Coverage snapshot / snapshot (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
Library trace revalidation (weekly) / revalidate (push) Has been cancelled

defineErrorMiddleware matched by instanceof only; the custom server's
tsx-bound DI graph and the Next-compiled route handler each load their
own copy of every error class, so mapped domain errors (e.g.
UnauthenticatedError -> UNAUTHORIZED) fell through and surfaced as raw
500s — hit in production by a downstream app built from this template.
Match on the canonical error name (probed from the constructor's
string-literal this.name) with instanceof as the fast path; nameless
classes stay identity-only so plain Errors never translate. ADR-027
records the decision and the now-load-bearing this.name convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 18:25:38 +02:00
parent 3d52f2de85
commit ed59b4348f
4 changed files with 165 additions and 3 deletions

View File

@@ -17,6 +17,21 @@ class FooBadRequestError extends Error {
}
}
// Structurally identical to FooNotFoundError but a distinct class identity —
// simulates the production failure where the DI-bound feature modules and the
// route-handler bundle each load their own copy of an error module, so the
// thrown instance fails `instanceof` against the registered constructor.
class CrossRealmFooNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "FooNotFoundError";
}
}
// Never sets `this.name` — probes as the inherited "Error". Registering it
// must not cause every plain Error to match by name.
class AnonymousDomainError extends Error {}
const errorRouter = t.router({
notFound: t.procedure
.use(
@@ -43,6 +58,21 @@ const errorRouter = t.router({
.query(() => {
throw new Error("plain");
}),
crossRealm: t.procedure
.use(defineErrorMiddleware([[FooNotFoundError, "NOT_FOUND"]]))
.query(() => {
throw new CrossRealmFooNotFoundError("other copy");
}),
namelessMapped: t.procedure
.use(defineErrorMiddleware([[AnonymousDomainError, "CONFLICT"]]))
.query(() => {
throw new Error("plain");
}),
namelessInstance: t.procedure
.use(defineErrorMiddleware([[AnonymousDomainError, "CONFLICT"]]))
.query(() => {
throw new AnonymousDomainError("actual instance");
}),
});
describe("defineErrorMiddleware", () => {
@@ -75,6 +105,31 @@ describe("defineErrorMiddleware", () => {
}
});
it("matches by error name when the instance comes from a different module graph", async () => {
// Production regression: tsx-bound DI modules and the webpack-bundled
// route handler hold separate copies of the error classes, so identity
// (`instanceof`) fails and mapped errors surfaced as 500s.
const caller = errorRouter.createCaller({});
await expect(caller.crossRealm()).rejects.toMatchObject({
code: "NOT_FOUND",
message: "other copy",
});
});
it("does not name-match plain Errors against a class that never set a custom name", async () => {
const caller = errorRouter.createCaller({});
await expect(caller.namelessMapped()).rejects.toMatchObject({
code: "INTERNAL_SERVER_ERROR",
});
});
it("still matches a nameless class by identity", async () => {
const caller = errorRouter.createCaller({});
await expect(caller.namelessInstance()).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("preserves the original error as the cause", async () => {
const caller = errorRouter.createCaller({});
try {

View File

@@ -20,17 +20,37 @@ type ErrorCtor = new (...args: never[]) => Error;
*
* Owned by features: each feature passes its own constructors in.
* core-shared never enumerates feature-specific error classes.
*
* Matching is by `instanceof` OR by canonical error name: the DI-bound
* feature modules and the app's route-handler bundle can each load their
* own copy of an error class (separate module graphs), so identity checks
* fail across them and mapped errors would surface as 500s. Every domain
* error sets `this.name` from a string literal in its constructor — that
* string survives duplication and minification, so the middleware probes
* one instance per constructor to learn its canonical name and matches
* `cause.name` against it. A class that never sets a custom name probes
* as the inherited "Error"; name-matching is disabled for it (identity
* only), otherwise every plain Error would match.
*/
export function defineErrorMiddleware(
map: ReadonlyArray<readonly [ErrorCtor, TRPC_ERROR_CODE_KEY]>,
) {
const entries = map.map(([Ctor, code]) => {
const probe = new (Ctor as new (...args: unknown[]) => Error)("__probe__");
return {
Ctor,
name: probe.name === "Error" ? undefined : probe.name,
code,
};
});
return t.middleware(async ({ next }) => {
const result = await next();
if (!result.ok) {
const cause = result.error.cause;
if (cause instanceof Error) {
for (const [Ctor, code] of map) {
if (cause instanceof Ctor) {
for (const { Ctor, name, code } of entries) {
const matchesName = name !== undefined && cause.name === name;
if (matchesName || cause instanceof Ctor) {
throw new TRPCError({ code, message: cause.message, cause });
}
}