Adapters
tRPC
Procedure-keyed changes with four touch points — context, middleware, error formatter, responseMeta.
tRPC is procedure-keyed instead of route-keyed: changes target
procedures: ["demo.userList"] and are indexed as trpc:demo.userList. The
same request/response transforms cover both transports:
v.change("2026-05-14", {
describe: "user.name split into firstName/lastName",
routes: ["GET /users", "GET /users/:id", "POST /users"],
procedures: ["demo.userList", "demo.userCreate"], // same change, both transports
request: { up: (body: UserCreateV1): UserCreate => splitName(body) },
response: { down: (body: User | User[]): UserV1 | UserV1[] => ... },
});request.up applies to the procedure input, response.down to its output.
Full wiring
HTTP headers aren't visible inside procedure middleware, and one HTTP request may batch several procedure calls — so the wiring has four touch points. This is how the repo's cloud server (apps/server) wires it:
// context.ts — 1. stash the resolve inputs on the tRPC context
import { versionlessContext, type VersionlessContext } from "@versionless/adapter-trpc";
import { v } from "./versionless";
export async function createContext({ req }: { req: Request }) {
return {
// ...your auth/session fields...
...versionlessContext(v, { req }),
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;// trpc.ts — 2. error formatter, 3. the procedure middleware
import { initTRPC } from "@trpc/server";
import { versionlessErrorFormatter, versionlessMiddleware } from "@versionless/adapter-trpc";
import type { Context } from "./context";
import { v } from "./versionless";
export const t = initTRPC.context<Context>().create({
errorFormatter: versionlessErrorFormatter(v), // error shapes are versioned too
});
export const router = t.router;
export const publicProcedure = t.procedure;
// Attach BEFORE .input(...) so up() runs pre-validation.
export const versionedProcedure = publicProcedure.use(versionlessMiddleware());// routers/demo.ts — procedures speak the current shape only
import { z } from "zod";
import { userCreateSchema, userSchema } from "./schema";
export const demoRouter = router({
userList: versionedProcedure.output(z.array(userSchema)).query(() => users),
userCreate: versionedProcedure
.input(userCreateSchema)
.output(userSchema)
.mutation(({ input }) => addUser(input)),
});// server — 4. sunset headers on the HTTP envelope
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { versionlessResponseMeta } from "@versionless/adapter-trpc";
fetchRequestHandler({
endpoint: "/trpc",
router: appRouter,
req: request,
createContext,
responseMeta: versionlessResponseMeta(v),
});What each piece does
| Helper | Where | Job |
|---|---|---|
versionlessContext(v, { req }) | createContext | Stashes the instance + a header getter on ctx.versionless. The middleware opens the per-procedure exchange itself, because a batched request needs one exchange per procedure, not per HTTP request. |
versionlessMiddleware() | t.procedure.use(...) | Opens the exchange for opts.path, ups the raw input before .input() validation (by overriding getRawInput, mirroring tRPC's own input middleware mechanism), downs the output, emits telemetry. |
versionlessErrorFormatter(v) | initTRPC.create | Applies registered error.down transforms to the error shape for old clients. |
versionlessResponseMeta(v) | fetchRequestHandler | Adds RFC 9745 Deprecation / RFC 8594 Sunset headers to the HTTP envelope, without compiling any transform pipeline. |
Failure modes and edge cases
- Missing context stash. If the middleware runs without
versionlessContextincreateContext, it warns once and serves the current shape — versioning silently off, requests still work. - Sunset past cutoff surfaces as
PRECONDITION_FAILED(tRPC has no 410 code), with the structured{ error: "api_version_sunset", version, sunset, message }body incause. - Invalid version strings throw
BAD_REQUESTwith the parse message. - Sync-only surfaces. tRPC v11 error formatters and
responseMetaare synchronous. If your resolve chain has an asyncapiKeyresolver, or anerror.downtransform is async, those two helpers pass the shape/headers through unchanged — the middleware (which can await) is unaffected. - Rewrites don't apply to tRPC. Express a procedure rename as an alias procedure that calls the same resolver.