在 Express.js 中接入 tRPC:使用官方适配器构建类型安全的端到端 API(v9.x 实战指南)
本指南基于 tRPC 官方 v9.x 文档《Usage with Express.js》编写,讲解如何把 tRPC 无缝接入既有 Express.js 项目:从安装依赖、定义类型安全的路由,到通过内置 Express 适配器将整个 Router 挂载为中间件,最终获得可被 HTTP 直接调用的 API 端点。读完本文,你将掌握 createExpressMiddleware 的完整接入流程、Context 的注入方式,并能参照仓库内的完整示例工程直接上手。
背景:tRPC 如何与 Express.js 结合
tRPC 的核心价值在于端到端类型安全:在服务端用 TypeScript 定义 Router 后,客户端可以在不编写任何接口文档、不做手工类型声明的情况下,获得与后端完全一致的自动补全与编译期类型检查。Express.js 是 Node.js 生态中最常用的 HTTP 框架之一,因此 tRPC 官方内置了针对 Express 的适配器,把 Router 包装成标准的 Express 中间件,使其可以与其他 Express 路由、中间件生态共存。
仓库中与本主题强相关的配套资源包括:
- 完整可运行的示例工程:examples/express-server,展示带鉴权 Context、订阅(EventEmitter)与批量调用的完整用法;更精简的入门版见 examples/express-minimal;
- 适配器源码:packages/server/src/adapters/express.ts;
- 适配器技能说明:packages/server/skills/adapter-express/SKILL.md。
版本说明:本文正文严格遵循 v9.x 文档所对应的
@trpc/serverv9 API(trpc.router()链式调用风格);文末会给出当前仓库(v10/v11 时代,使用initTRPC)的写法差异提示。本文中引用的源码均以当前仓库实际内容为准。
1. 安装依赖
在已有 Express.js 项目的根目录中执行:
yarn add @trpc/server zod
同样可以使用 npm install @trpc/server zod 或 pnpm add @trpc/server zod 完成安装。
其中 Zod 并不是必需的依赖,它只是下面示例 Router 中用来做输入校验的库。tRPC 的校验层是可替换的,若你的项目已经使用其他校验方案(或不需要输入校验),可以不安装 Zod。
2. 创建 tRPC Router
首先定义 API 的类型与逻辑。一个典型的 Router 同时包含查询(query)与变更(mutation)两类 Procedure,分别对应 HTTP 语义中的"读"与"写":
import * as trpc from '@trpc/server';
import { z } from 'zod';
const appRouter = trpc
.router()
.query('getUser', {
input: z.string(),
async resolve(req) {
req.input; // string
return { id: req.input, name: 'Bilbo' };
},
})
.mutation('createUser', {
// 用 Zod 校验输入
input: z.object({ name: z.string().min(5) }),
async resolve(req) {
// 这里可以接入任意 ORM
return await UserModel.create({
data: req.input,
});
},
});
// 导出 API 的类型定义,客户端将基于它获得类型推断
export type AppRouter = typeof appRouter;
这段代码的关键点:
.query('getUser', ...)定义只读操作,输入为z.string(),其resolve回调内req.input已被推断为string类型;.mutation('createUser', ...)定义写入操作,输入经z.object({ name: z.string().min(5) })校验(长度不足 5 的请求会在进入resolve前被自动拒绝);export type AppRouter = typeof appRouter是 tRPC 类型安全链路的源头——客户端import type { AppRouter } from './server'后即可获得完整类型。
关于路由文件膨胀:当 Router 文件过大时,应当把路由拆分为多个子 Router,每个子 Router 放在独立文件中,最后通过合并(merge)组合成一个根 appRouter。合并方式详见 v9 文档 merging-routers.md 以及 v9 Context 说明 context.md。仓库示例 examples/express-server/src/server.ts 演示了更完整的拆分子路由(postRouter、messageRouter、内联的 admin 路由)并合并进根 Router 的写法。
3. 使用 Express.js 适配器挂载路由
tRPC 开箱即用地包含了 Express 适配器(@trpc/server/adapters/express),它能把 Router 转换成一个标准的 Express 中间件:
import * as trpcExpress from '@trpc/server/adapters/express';
const appRouter = /* ... */;
const app = express();
// 该函数会在每次请求时被调用,用于构造 Context
const createContext = ({
req,
res,
}: trpcExpress.CreateExpressContextOptions) => ({}) // no context
type Context = trpc.inferAsyncReturnType<typeof createContext>;
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext,
})
);
app.listen(4000);
接入过程只需要三步,理解下面三个核心概念即可:
createExpressMiddleware:将 Router 与 Context 工厂包装成 Express 中间件,挂载在路径前缀/trpc下。参数对象中的router即上一步定义的appRouter。createContext与CreateExpressContextOptions:该工厂函数在每个请求到来时执行一次,接收 Express 的req与res对象。你可以在其中解析req.headers.authorization、读取 session、创建数据库连接等,返回值会成为所有 Procedure 的ctx。上例返回空对象,即"无 Context"。trpc.inferAsyncReturnType<typeof createContext>:把createContext的异步返回类型推导为Context类型,供后续给 Router 标注 Context 使用(v9 中配合trpc.router<Context>()显式声明)。
在 examples/express-server/src/server.ts 中可以看一个带鉴权的 createContext 实战写法:它读取 req.headers.authorization,当值不为 'secret' 时返回 user: null,否则返回用户对象;随后 admin.secret 这类受保护 Procedure 依赖该 ctx.user 抛出 TRPCError(UNAUTHORIZED / FORBIDDEN)。
端点如何映射到 HTTP
挂载完成后,你的 Procedure 立刻可以通过 HTTP 访问:
| 端点 | HTTP URI |
|---|---|
getUser |
GET http://localhost:4000/trpc/getUser?input=INPUT,其中 INPUT 是 URI 编码后的 JSON 字符串 |
createUser |
POST http://localhost:4000/trpc/createUser,req.body 形如 {name: string}(且需满足 min(5) 校验) |
即 Procedure 的完整路径 = 挂载前缀 /trpc + Procedure 名称。GET 请求通过 ?input= 传递 URI 编码的 JSON 输入;POST 请求则把输入放在请求体中。tRPC 的 HTTP 层会自动完成序列化/反序列化与 Zod 校验,非法输入会返回结构化的错误响应。
4. 适配器源码原理:中间件背后发生了什么
以当前仓库的适配器实现为参照,packages/server/src/adapters/express.ts 展示了这条链路的本质:
export function createExpressMiddleware<TRouter extends AnyRouter>(
opts: NodeHTTPHandlerOptions<TRouter, express.Request, express.Response>,
): express.Handler {
return (req, res) => {
let path = '';
run(async () => {
// 从完整请求路径中截取 Procedure 名
path = req.path.slice(req.path.lastIndexOf('/') + 1);
await nodeHTTPRequestHandler({ ...opts, req, res, path });
}).catch(internal_exceptionHandler({ req, res, path, ...opts }));
};
}
从中可以看出三点实现事实:
createExpressMiddleware的返回值就是一个express.Handler((req, res) => void),因此可以直接传给app.use,也可以作为子中间件与其他 Express 路由自由组合;- 中间件内部从
req.path的最后一个/之后截取路径段作为 Procedure 名,这就是为什么挂载前缀与 Procedure 名必须拼成/<prefix>/<procedureName>的形式; - 真正的请求处理委托给 node-http 模块中的
nodeHTTPRequestHandler,这意味着 Express 适配器与独立服务器(standalone)、其它 Node HTTP 适配器共享同一套 HTTP 处理核心;出错时通过internal_exceptionHandler统一处理异常并写出错误响应。
5. 完整可运行示例与客户端调用
精简版(express-minimal)
examples/express-minimal 是最小可运行示例:
- 路由定义 src/router.ts 使用
initTRPC风格(t.router({...}))定义了一个带可选name入参的hello.greeting查询; - 服务端 src/server.ts 额外保留了一个常规 Express 路由
GET /(用于健康检查/测试等待),再把 tRPC 中间件挂载到/trpc,最后app.listen(3000)。
这展示了一种常见形态:tRPC 中间件与既有 Express 路由并存,两者互不干扰。
完整版(express-server)
examples/express-server 则覆盖了更多真实场景,包括订阅式消息推送(基于 Node EventEmitter)、分层子路由、受鉴权保护的 admin.secret 等。其客户端 src/client.ts 演示了 tRPC 客户端的典型用法:
import { createTRPCClient, httpBatchLink, loggerLink } from '@trpc/client';
import type { AppRouter } from './server';
const trpc = createTRPCClient<AppRouter>({
links: [
loggerLink(),
httpBatchLink({ url: `http://localhost:2021/trpc` }),
],
});
注意 AppRouter 是从服务端 import type 而来的——这正是"类型安全的端到端"的落点:客户端不用手写任何接口签名。此外该示例还展示了:
- 批处理:
httpBatchLink可以把多个查询合并为单个 HTTP 请求(示例中Promise.all并发调用两次hello.query); - 按请求注入 Header:
httpBatchLink的headers选项返回{ authorization: 'secret' },与服务端createContext的鉴权逻辑一一对应,从而解锁admin.secret查询;未携带该 Header 时则被服务端以TRPCError拒绝。
6. 与既有 Express 路由共存时的最佳实践
基于 packages/server/skills/adapter-express/SKILL.md 与仓库示例,接入时建议注意以下几点:
6.1 常规 REST 路由与 tRPC 共存
无需把整个应用都迁移到 tRPC。健康检查、静态资源、回调接口等仍可用原生 Express 路由,再把 /trpc 前缀交给 tRPC 适配器即可。示例如下(同时可叠加 cors() 等 Express 生态中间件):
import * as trpcExpress from '@trpc/server/adapters/express';
import cors from 'cors';
import express from 'express';
import { createContext } from './context';
import { appRouter } from './router';
const app = express();
app.use(cors());
app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext,
}),
);
app.listen(4000);
6.2 不要在 tRPC 之前全局注册 express.json()
高优先级提醒:如果先全局执行 app.use(express.json()) 再挂载 tRPC 中间件,全局 body 解析器会先消费并解析请求体,导致 tRPC 收到的请求体已被改写,从而破坏 multipart/form-data(FormData)与二进制内容类型的处理。推荐做法是只把 JSON body 解析器限定在非 tRPC 路由上:
const app = express();
// 仅对 /api 下的非 tRPC 路由启用 body 解析
app.use('/api', express.json());
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({ router: appRouter, createContext }),
);
这也解释了为何示例工程 examples/express-server/src/server.ts 中没有对 /trpc 启用全局 express.json()。
6.3 控制批处理上限 maxBatchSize
若你的客户端启用了批处理,可在服务端中间件选项中设置 maxBatchSize,防止单个请求携带过多操作:
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext,
maxBatchSize: 10,
}),
);
超过 maxBatchSize 的批处理请求会被以 400 Bad Request 拒绝。客户端侧应同步把 httpBatchLink 的 maxItems 设为相同数值,避免请求超限。
7. 版本演进提示:v9 与当前 v10/v11 写法的差异
v9.x 文档中的 Router 使用 trpc.router().query(...) 链式风格。在 v10/v11(即本仓库当前主线的 packages/server 与现行文档 www/docs/server/adapters/express.md)中,写法统一改为先 initTRPC 初始化实例,再通过 t.router / t.procedure 定义,例如:
import { initTRPC } from '@trpc/server';
import * as trpcExpress from '@trpc/server/adapters/express';
const t = initTRPC.context<Context>().create();
const appRouter = t.router({
greet: t.procedure
.input(z.object({ name: z.string() }))
.query(({ input }) => ({ greeting: `Hello, ${input.name}!` })),
});
但适配器的接入形态没有本质变化:createExpressMiddleware、createContext、CreateExpressContextOptions、app.use('/trpc', ...) 的核心用法与本文一致(express-minimal 与 express-server 两个示例即运行在当前写法之上)。从 v10 迁移到 v11 的完整细节可参考 migrate-from-v10-to-v11.mdx。
总结
在 Express.js 项目中接入 tRPC 只需要三步:安装依赖 → 定义(可拆分的)Router → 用 createExpressMiddleware 将其挂载到 /trpc 前缀。整个过程不需要额外启动独立服务,所有 Procedure 立即成为可被 HTTP 访问的端点,同时客户端借由 AppRouter 类型获得零成本的端到端类型安全。若想直接运行体验,参考 examples/express-minimal 与 examples/express-server 两个示例工程的 package.json 脚本即可快速启动。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00