这节在地图里是"TS 落地到服务端",也是全栈类型共享的枢纽。后端的价值不在 UI,而在"请求/响应契约"和"数据模型契约"。这两类契约一旦和前端共享,就能消灭一大类"字段对不上"的联调 bug。
用可辨识联合描述 API 响应(第二章、第三章讲过基础),让调用方被迫处理错误。
// shared/types.ts —— 前后端都能 import export interface CreateOrderReq { productId: string; quantity: number; } export type ApiResponse<T> = | { ok: true; data: T } | { ok: false; code: number; message: string }; // 服务端处理 import { Request, Response } from "express"; function handle(req: Request<{}, {}, CreateOrderReq>, res: Response) { const body = req.body; // 类型 CreateOrderReq // ... }
输入输出:前端发的 CreateOrderReq 与服务端收的是同一类型,字段改名时两端同时标红。契约在编译期把前后端"焊"在一起。
主流 Node ORM(如 Prisma、TypeORM)能从模型定义生成类型,查询结果的每行都带精确类型。
// Prisma 模型生成后的用法(简化) import { PrismaClient } from "@prisma/client"; const db = new PrismaClient(); const user = await db.user.findUnique({ where: { id: 1 } }); // user: User | null,字段类型由 schema 生成 if (user) user.email; // 精确
背景:手写 SQL 返回的是 any,字段名拼错运行时才知。操作:用 ORM 生成的类型。结果:查询结果每行都有类型。解读:ORM 类型本质是"数据库 schema 到 TS 类型的桥",契约从存储层一路通到前端。
// shared/contracts.ts export interface UserDTO { id: number; name: string; role: "admin" | "guest"; } // 前端 import type { UserDTO } from "shared/contracts"; function render(u: UserDTO) {} // 后端 import type { UserDTO } from "shared/contracts"; function getUser(): UserDTO { /* ... */ }
这张图展示类型如何跨层流动:

第二章说过,类型擦除后运行时无保护。后端接收外部请求,必须用运行时 schema 校验兜住。
import { z } from "zod"; const ReqSchema = z.object({ productId: z.string(), quantity: z.number().int().positive(), }); // 在边界解析,失败直接 400 const parsed = ReqSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error });
z.infer 还能从 schema 反推 TS 类型,做到"一份定义同时给运行时和编译期用",避免类型与校验两处维护脱节。
全栈共享类型最容易出的问题是"前端一份、后端一份、DB 一份"三处漂移。我们主张:DB schema → ORM 类型 → shared 契约 → 前端消费,尽量让上游自动生成下游,而不是每处手写。能用 z.infer/Prisma 生成就别手敲,单一来源是类型安全的地基。
把"可辨识联合响应"的处理抽成泛型工具,调用方一次写对、处处复用。
function unwrap<T>(res: ApiResponse<T>): T { if (res.ok) return res.data; throw new Error(`API ${res.code}: ${res.message}`); } // 用法:解包即拿到 data,错误分支已被收窄排除 const r = unwrap<{ id: number }>({ ok: true, data: { id: 1 } }); // r: {id:number}
输入输出:传入成功响应,unwrap 返回精确的 data 类型;传入失败响应则抛错。调用点不再需要手写 if (res.ok) 收窄,易错的人工分支被收进工具。解读:这正是第二章"可辨识联合 + 收窄"在服务端的落地,类型保证你处理了错误分支。
后端 DTO 最忌"schema 一份、interface 一份"两处漂移。用 zod 的 infer 让类型从校验 schema 长出。
import { z } from "zod"; const CreateUser = z.object({ name: z.string().min(1), age: z.number().int().nonnegative(), }); type CreateUserDTO = z.infer<typeof CreateUser>; // 类型由 schema 推出 // 边界校验 const parsed = CreateUser.safeParse(req.body); if (!parsed.success) return res.status(400).json(parsed.error); // parsed.data 的类型就是 CreateUserDTO,运行时与编译期一致
背景:手写 DTO 后再手写校验,两边易不同步。操作:只写 zod schema,类型用 z.infer 派生。结果:校验规则即类型定义,改一处两处都变。解读:这是第七章"单一来源"主张中最彻底的形态——运行时校验和编译期类型共用一个真相,详见第十章选型方向。
把"路径参数 + 响应数据"用泛型绑进路由处理器,注册路由时即获得类型,避免每个 handler 手写一遍。
import { Request, Response } from "express"; // 泛型封装:P=路径参数,B=请求体,R=响应数据 type Handler<P, B, R> = ( req: Request<P, {}, B>, res: Response<ApiResponse<R>> ) => void; const getOrder: Handler<{ id: string }, {}, { id: string; amount: number }> = (req, res) => { const { id } = req.params; // id: string res.json({ ok: true, data: { id, amount: 100 } }); }; // 注册时 handler 签名已被约束,写错参数类型编译期暴露
背景:路由多时,每个 handler 手动标 Request<...> 冗长且易错。操作:抽 Handler<P,B,R> 泛型,路由注册统一用它。结果:路径参数、响应数据全程保类型。解读:这是把前端"泛型 hook"思路搬到后端路由边界,契约从入口贯穿到响应。
后端常用容器管理 service 实例。用 TS 把"注册什么类型、取出什么类型"钉死,避免运行时 as 强转。
class Container { private map = new Map<string, unknown>(); register<T>(key: string, inst: T): void { this.map.set(key, inst); } resolve<T>(key: string): T { const v = this.map.get(key); if (!v) throw new Error(`未注册: ${key}`); return v as T; // 容器不验证,调用方保证 key 与 T 对应 } } interface Mailer { send(to: string): void; } const c = new Container(); c.register<Mailer>("mailer", { send: (to) => console.log(to) }); const m = c.resolve<Mailer>("mailer"); // 取出的就是 Mailer m.send("a@b.com");
背景:手写单例或全局变量,取出时往往 any。操作:用 register<T>/resolve<T> 标注。结果:取出即精确类型。解读:容器本身不校验 key 与 T 的对应(那是运行期约定),但至少让"取用处"有类型,比裸 any 强;配合 key 用字面量联合还能进一步收紧。
实时通信里"事件名 + 载荷"也适合可辨识联合,前后端共用一份事件契约。
// shared/events.ts export type ClientEvent = | { type: "join"; room: string } | { type: "msg"; text: string }; // 服务端派发时按 type 收窄,拼错字段编译期拦 function dispatch(e: ClientEvent) { if (e.type === "join") console.log(e.room); else console.log(e.text); }
背景:长连接消息结构多,手写 any 易错位。操作:用联合描述事件。结果:处理函数内精确收窄。解读:这与前端组件事件、后端 API 响应共用同一套"可辨识联合"工具,是全书契约主线在服务端实时场景的延伸。
⚠️ 别以为全栈共享了 TS 类型就安全了。类型只在编译期存在,外部请求体照样是任意 JSON;不接运行时校验,恶意或错误输入仍能进业务逻辑。
💡 让类型有"单一来源":优先用 ORM/zod 生成 TS 类型,而不是前端后端各写一份 interface,能彻底消灭"字段改了那边没改"的漂移 bug。