tRPC v10 Router Architecture: Modularizing Server Instance and Router Definitions
0 reputation · 23 Mar 2023, 08:24 UTC
0 reputation · 23 Mar 2023, 08:24 UTC
The shift from tRPC v9 to v10 introduced a redesigned API architecture that decouples the router definition from the server instance. This change is intended to improve tree-shaking and allow for a more granular TypeScript-first approach to API definitions.
When implementing complex nested routers, there is a design trade-off between maintaining strict type-safety across the entire API surface and the flexibility required for dynamic routing patterns. Ensuring that the @trpc/server package maintains type integrity during this separation requires precise generic definitions.
34775 reputation · 23 Mar 2023, 13:33 UTC
1. Generic propagation in deeply nested routers
t.router value created by the same initTRPC factory that produced the t object. All nested routers inherit the Context and Metadata generics that were fixed at initTRPC.context().create(). Because the router type is built from that factory, the compiler can compose the input/output types of every procedure structurally, no matter how many levels of nesting.t instance, the root AppRouter type will contain the exact procedure signatures of all nested routers. The separation of the router from the server adapter does not introduce a new generic boundary; the server adapter receives only a runtime value and never participates in the type derivation.2. Tree‑shaking with shared middleware
router.use(middleware). The middleware becomes a transitive dependency of every procedure that calls .use(). In a bundler that performs tree‑shaking, any bundle that imports a router that uses the middleware will include the middleware code and all of its imports.initTRPC factory; mixing factories with different Context generics causes a compile‑time error.Context or the procedure types.AppRouter type via import type { AppRouter } from "./router" and use createTRPCProxyClient() without pulling in any server runtime code.ctx (e.g., adding ctx.user) must be applied where the procedures that read it are defined. If you apply the middleware after a procedure that expects the augmentation, you’ll get a runtime error or a type mismatch.npm list @trpc/server should show 10.x before using the API names below.t factory and builds a sub‑router:
import { t } from "./trpc";
export const postsRouter = t.router({
create: t.procedure.input(z.object({ title: z.string() })).mutation(() => { /*...*/ })
});
import { t } from "./trpc";
import { postsRouter } from "./posts";
export const appRouter = t.router({ posts: postsRouter });
export type AppRouter = typeof appRouter;
tsc --noEmit and inspect inferRouterOutputs<AppRouter> to ensure the nested procedure types are present.initTRPC instance with a different Context type; TypeScript should surface an error, confirming the single‑factory rule.webpack-bundle-analyzer or rollup-plugin-visualizer and compare bundle sizes when you import versus omit a feature router.To refine the recommendation, could you confirm whether all feature routers are built from the same initTRPC factory and use a shared Context type? If you have per‑feature context augmentations, we may need to adjust the middleware strategy.
Use comments to ask for clarification. Post a solution as an answer.
5,550 reputation · 23 Mar 2023, 13:49 UTC
On the tree-shaking question: the decoupled router design helps, but elimination of unused shared middleware still depends on the bundler correctly analyzing side effects. If a middleware file is imported by multiple router modules but only some procedures using it remain in the final merged router, the bundler must prove the middleware has no side effects to drop it. Mark middleware/router modules with "sideEffects": false in package.json and use an ESM-aware bundler (webpack 5+, Rollup, esbuild, Turbopack). Without this, the middleware and its transitive dependencies are retained even when no live procedure references them.