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:
@@ -15,11 +15,14 @@ doc-update items so docs are written once for the post-Plan-9 state.
|
||||
|
||||
## 1. Files added
|
||||
|
||||
(populated as work progresses)
|
||||
- packages/core-shared/src/trpc/define-error-middleware.ts — middleware factory mapping [ErrorCtor, TRPC_CODE] tuples to TRPCError translation
|
||||
- packages/core-shared/src/trpc/define-error-middleware.test.ts — 4 tests covering mapped translation, multiple codes, unmapped passthrough (verifies INTERNAL_SERVER_ERROR + cause preservation), cause preservation
|
||||
|
||||
## 2. Files modified
|
||||
|
||||
(populated as work progresses)
|
||||
- packages/core-shared/src/trpc/init.ts — `t` instance now exported (was internal const) so feature procedures.ts can do `t.procedure.use(...)`
|
||||
- packages/core-shared/package.json — added "./trpc/define-error-middleware" subpath export
|
||||
- packages/core-shared/tsconfig.json — set `rootDir: "."` and added `@/*` path alias so test files using `@/` resolve correctly under `tsc --noEmit`
|
||||
|
||||
## 3. Pattern changes (code-level)
|
||||
|
||||
@@ -34,7 +37,10 @@ doc-update items so docs are written once for the post-Plan-9 state.
|
||||
|
||||
## 4. Error-middleware adoption
|
||||
|
||||
(populated when defineErrorMiddleware is wired in core-shared and consumed by features)
|
||||
- core-shared infrastructure landed; feature routers will adopt in Tasks 3-7.
|
||||
- Discriminator: `instanceof Ctor` (not error name string), so duck-typing is impossible — features pass their own class constructors.
|
||||
- Cause preservation: TRPCError carries the original domain error in `.cause` for client structured-error inspection.
|
||||
- Note on tRPC v11 behavior: unmapped errors still surface as `TRPCError(code: INTERNAL_SERVER_ERROR)` because tRPC's `createCaller` wraps all procedure errors. Our middleware does not interfere — the original error is preserved as `.cause` for inspection.
|
||||
|
||||
## 5. Public-API surface
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
36
packages/core-shared/src/trpc/define-error-middleware.ts
Normal file
36
packages/core-shared/src/trpc/define-error-middleware.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { initTRPC } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
|
||||
const t = initTRPC.create({
|
||||
export const t = initTRPC.create({
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user