tRPC 极简实战:用 Express + Vanilla TRPCClient 搭建端到端类型安全 API
本篇以 tRPC 仓库中的 Express 最小示例为蓝本,演示如何用最少代码搭出一个完整的 tRPC 应用:一个挂载 createExpressMiddleware 的 Express 服务端,加一个运行在 Node 环境中的 Vanilla TRPCClient。读完本文,你将掌握该示例的目录结构、启动方式、端到端类型推导是如何工作的,以及修改服务端程序名或输入时客户端为何会立即报类型错误。
示例包含什么
examples/express-minimal/README.md 明确说明该示例只包含两样东西:
- Express server:基于 Express 5 的 HTTP 服务,通过 tRPC 官方 Express 适配器暴露
/trpc路由; - Vanilla TRPCClient in Node:不使用 React、不使用浏览器,直接在 Node 进程里用
@trpc/client发起类型安全的请求。
这是理解 tRPC 最底层的“去框架化”形态:不依赖 @trpc/react-query、next 等上层集成,只看 core 部分——Router、Procedure、Adapter、Link 四件套如何组合成一个可运行的 API。
运行方式
在 examples/express-minimal/package.json 中,各脚本定义如下:
# 同时启动 server 与 client(README 推荐入口)
yarn start # 实际是 pnpm dev,即 run-p dev:* --print-label
# 拆解开来:
dev:server # tsx watch src/server —— 监听并运行服务端
dev:client # wait-port 3000 && tsx watch src/client —— 等端口就绪后运行客户端
# 生产形态:esbuild 打包后分别运行
build # esbuild src/server.ts src/client.ts --bundle ... --outdir=dist
test-start # start-server-and-test 'node dist/server' 3000 'node dist/client'
几个值得注意的细节:
- 客户端脚本用
wait-port 3000等待服务端先监听 3000 端口,避免连接被拒; test-dev/test-start使用start-server-and-test把“起服务、等端口、跑客户端”串成一个自动化流程,这是该示例被当作集成测试用例的入口;- 依赖面非常小:
express@^5.0.0、zod@^4.2.1加上@trpc/server、@trpc/client、@trpc/react-query(其中 react-query 在该示例里并未真正使用),运行时代码只有 server/router/client 三个文件。
服务端:Express 适配器
完整代码见 src/server.ts:
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import express from 'express';
import { appRouter } from './router';
async function main() {
const app = express();
// 供 wait-port / 健康检查使用的根路由
app.get('/', (_req, res) => {
res.send('Server is running!');
});
app.use(
'/trpc',
createExpressMiddleware({
router: appRouter,
}),
);
app.listen(3000);
}
void main();
要点拆解:
createExpressMiddleware({ router })返回一个标准 Express handler,挂载在/trpc前缀下,所有 tRPC 请求都以http://localhost:3000/trpc/<procedure>的形式到达;GET /路由只是为“等待端口就绪”提供的探测端点,与 tRPC 无关;- 适配器本身极薄。从源码 packages/server/src/adapters/express.ts 看,
createExpressMiddleware只是取出请求路径的最后一段作为 procedure path,然后委托给通用的nodeHTTPRequestHandler处理,异常统一交给internal_exceptionHandler。也就是说 Express 适配器与 standalone、fetch 等 Node 系适配器共享同一套 HTTP 处理内核,差异仅在如何读取req.path与如何挂到框架上。
官方文档中对应的适配器说明可参考 www/docs/server/adapters/express.md。
路由定义:嵌套 Router 与 nullish 输入
Router 见 src/router.ts,共 17 行,展示了 v11 的嵌套路由写法:
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const publicProcedure = t.procedure;
const router = t.router;
export const appRouter = router({
hello: {
greeting: publicProcedure
.input(z.object({ name: z.string() }).nullish())
.query(({ input }) => {
return `Hello ${input?.name ?? 'World'}`;
}),
},
});
export type AppRouter = typeof appRouter;
几个关键设计:
- 嵌套 router:
hello本身是一个子 router,greeting是其中的 query,因此完整 procedure 路径为hello.greeting,客户端按client.hello.greeting的点分路径访问; nullish()输入:z.object({ name: z.string() }).nullish()使 input 同时允许undefined/null与{ name: string },服务端用input?.name ?? 'World'兜底,实现“可选参数”的端到端类型化;AppRouter类型导出:typeof appRouter是整套类型安全体系的枢纽——客户端导入这个类型后,所有 procedure 名、input、output 都由它推导。
客户端:Vanilla TRPCClient + httpBatchLink
Node 端客户端见 src/client.ts:
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './router';
async function main() {
const client = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
});
try {
const withoutInputQuery = await client.hello.greeting.query();
console.log(withoutInputQuery);
const withInputQuery = await client.hello.greeting.query({ name: 'Alex' });
console.log(withInputQuery);
} catch (error) {
console.error('Error:', error);
}
}
void main();
流程是:createTRPCClient<AppRouter> 把路由类型“压入”客户端,links 数组决定请求如何发出。这里只有一个 httpBatchLink,指向服务端的 /trpc 挂载点。两次 query() 调用分别演示了“无 input”与“带 input”两种调用形态,返回值类型分别由 router 中的 handler 推导。
从 packages/client/src/links/httpBatchLink.ts 的实现看,batch link 内部按 query / mutation 各建了一个 Data Loader,同一 tick 内发起的多个请求会被合并为一次 HTTP 调用:procedure path 用逗号拼接进 URL、input 数组以 JSON 放入请求体,服务端逐个解析后按序返回结果数组;如果传入 maxURLLength / maxItems(默认均为 Infinity),超出阈值则自动拆批。此外该文件明确抛错提示 httpBatchLink 不支持 subscription,需要订阅时应改用 httpSubscriptionLink 或 wsLink——这与本示例“纯 query”的定位正好吻合。
端到端类型安全:README 里的“试一试”
README 给出了一条最直观的验证建议(原文):
Tip: Try changing either the procedure name or it's input on the server and see the client type-erroring.
把这条建议展开,可以做一个小实验:
- 在 src/router.ts 中把
greeting改名为greet,或把 input 的name字段改成number; - 保存后,由于 tsconfig.json 开启了
"strict": true,tsc(即typecheck脚本)会立刻在 src/client.ts 中报错:Property 'greeting' does not exist,或 input 参数类型不匹配; - 类型错误的根源是 client.ts 第 2 行的
import type { AppRouter } from './router'——注意客户端与服务端共享的是同一个router.ts模块而非复制的类型,createTRPCClient<AppRouter>据此精确推导出可访问的 procedure 树和各自的 input/output 类型。
这正是 tRPC “Move Fast and Break Nothing” 的最小证据:破坏性改动(改 procedure 名、改 input 形状)在编译期即被拦截,而不是等到运行时收到 404 或校验失败。
小结
该示例用三个约 20 行的文件(server.ts、router.ts、client.ts)完整覆盖了 tRPC 的核心抽象:initTRPC 初始化与嵌套 Router、Zod 可选输入、Express 适配器中间件、createTRPCClient + httpBatchLink 的 Node 客户端,以及 AppRouter 类型驱动的端到端类型检查。适合作为学习 tRPC v11 内部机制的起点;当你需要在浏览器或 React 环境中使用它时,可进一步参考仓库中的其他示例,例如 examples/express-server 与 examples/minimal。
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 StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00