feat(core-shared): add defineErrorMiddleware factory + export t

Factory takes [[ErrorCtor, TRPC_CODE], ...] tuples and returns a tRPC
middleware that translates matching domain errors to TRPCError. Discrim-
inates by instanceof; preserves original error as cause; unmapped
errors propagate (tRPC then wraps them as INTERNAL_SERVER_ERROR with
the original error as .cause — middleware does not interfere).

core-shared never enumerates feature errors — each feature passes its
own constructors in via integrations/api/procedures.ts (Tasks 3-7).

Also exports the `t` instance from trpc/init.ts so feature procedure
files can do t.procedure.use(...).

Also fixes tsconfig.json: rootDir set to "." and @/* path alias added
so test files using @/ resolve correctly under tsc --noEmit.

Refactor log: §1, §2, §4
Spec: R13–R17
This commit is contained in:
2026-05-06 11:43:45 +02:00
parent 61baaae99a
commit e25b1f7a1c
6 changed files with 142 additions and 6 deletions

View File

@@ -7,7 +7,8 @@
".": "./src/index.ts",
"./payload": "./src/payload/index.ts",
"./trpc/init": "./src/trpc/init.ts",
"./trpc/context": "./src/trpc/context.ts"
"./trpc/context": "./src/trpc/context.ts",
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts"
},
"scripts": {
"build": "tsc --noEmit",

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { TRPCError } from "@trpc/server";
import { t } from "@/trpc/init";
import { defineErrorMiddleware } from "@/trpc/define-error-middleware";
class FooNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "FooNotFoundError";
}
}
class FooBadRequestError extends Error {
constructor(message: string) {
super(message);
this.name = "FooBadRequestError";
}
}
const errorRouter = t.router({
notFound: t.procedure
.use(
defineErrorMiddleware([
[FooNotFoundError, "NOT_FOUND"],
[FooBadRequestError, "BAD_REQUEST"],
]),
)
.query(() => {
throw new FooNotFoundError("nope");
}),
badRequest: t.procedure
.use(
defineErrorMiddleware([
[FooNotFoundError, "NOT_FOUND"],
[FooBadRequestError, "BAD_REQUEST"],
]),
)
.query(() => {
throw new FooBadRequestError("oops");
}),
unmapped: t.procedure
.use(defineErrorMiddleware([[FooNotFoundError, "NOT_FOUND"]]))
.query(() => {
throw new Error("plain");
}),
});
describe("defineErrorMiddleware", () => {
it("translates a mapped error to TRPCError with the configured code", async () => {
const caller = errorRouter.createCaller({});
await expect(caller.notFound()).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("translates a different mapped error to a different code", async () => {
const caller = errorRouter.createCaller({});
await expect(caller.badRequest()).rejects.toMatchObject({
code: "BAD_REQUEST",
});
});
it("does not translate unmapped errors but preserves the original via cause", async () => {
const caller = errorRouter.createCaller({});
try {
await caller.unmapped();
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
const trpcErr = e as TRPCError;
expect(trpcErr.code).toBe("INTERNAL_SERVER_ERROR");
expect(trpcErr.cause).toBeInstanceOf(Error);
expect(trpcErr.cause).not.toBeInstanceOf(TRPCError);
expect((trpcErr.cause as Error).message).toBe("plain");
}
});
it("preserves the original error as the cause", async () => {
const caller = errorRouter.createCaller({});
try {
await caller.notFound();
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
const trpcErr = e as TRPCError;
expect(trpcErr.cause).toBeInstanceOf(FooNotFoundError);
expect((trpcErr.cause as Error).message).toBe("nope");
}
});
});

View File

@@ -0,0 +1,36 @@
import { TRPCError } from "@trpc/server";
import type { TRPC_ERROR_CODE_KEY } from "@trpc/server/rpc";
import { t } from "./init";
type ErrorCtor = new (...args: never[]) => Error;
/**
* Build a tRPC middleware that translates domain errors to TRPCError.
*
* Each tuple pairs a constructor with a TRPC error code. The middleware
* runs the procedure body inside a try/catch; on `instanceof Ctor`,
* it throws a TRPCError with the configured code and the original error
* preserved as `.cause`. Unmapped errors propagate untouched (tRPC's
* default INTERNAL_SERVER_ERROR handling applies).
*
* Owned by features: each feature passes its own constructors in.
* core-shared never enumerates feature-specific error classes.
*/
export function defineErrorMiddleware(
map: ReadonlyArray<readonly [ErrorCtor, TRPC_ERROR_CODE_KEY]>,
) {
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) {
throw new TRPCError({ code, message: cause.message, cause });
}
}
}
}
return result;
});
}

View File

@@ -1,7 +1,7 @@
import { initTRPC } from "@trpc/server";
import superjson from "superjson";
const t = initTRPC.create({
export const t = initTRPC.create({
transformer: superjson,
});

View File

@@ -2,7 +2,10 @@
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]