3. 类型系统在服务端的深度应用
在服务端工程中,TypeScript 的类型系统不只是“给变量加个类型”这么简单。它真正发挥价值的地方,是把 API 契约从口头约定、文档描述,变成编译期可校验、运行期可收窄的代码结构。接口定义请求与响应的形状,泛型让同一套逻辑适配不同资源,联合类型表达“多选一”的业务状态,类型守卫把宽泛的输入收窄为可信的具体类型,工具类型则负责在已有类型上做派生与裁剪。这五者组合起来,才构成一个可维护的服务端类型契约。
接口:把 API 契约写成可校验的形状
接口(interface)最适合描述稳定的对象结构,例如请求体、响应体、数据库实体和领域模型。服务端常见的做法是让接口承担“契约声明”的角色,而不是让控制器里散落匿名对象类型。下面是一个订单查询接口的契约示例,注意字段的可选性与只读性都直接表达了业务约束:
interface OrderQuery {
readonly tenantId: string;
status?: 'pending' | 'paid' | 'shipped' | 'closed';
page: number;
pageSize: number;
}
interface OrderSummary {
id: string;
amountCents: number;
status: 'pending' | 'paid' | 'shipped' | 'closed';
createdAt: string;
}
interface PageResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
这里 readonly tenantId 表示租户标识在查询过程中不可被改写,status? 表示该过滤条件可选,而 PageResult<T> 已经提前引入了泛型,让分页结构可以复用于订单、用户、商品等不同资源。接口的价值在于:当某个字段被重命名或类型被修改时,所有引用该接口的编译单元都会报错,而不是等到线上返回 500 才发现。
泛型:让分页、响应包装与仓储复用同一套骨架
服务端有大量“结构相同、元素类型不同”的场景,泛型正是为这类复用而生。统一响应包装是最典型的例子:
interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
function ok<T>(data: T): ApiResponse<T> {
return { code: 0, message: 'ok', data };
}
function fail(code: number, message: string): ApiResponse<null> {
return { code, message, data: null };
}
调用 ok(orderSummary) 时,返回类型自动推导为 ApiResponse<OrderSummary>,前端或调用方拿到 data 后无需再做类型断言。泛型约束还能进一步收紧范围,例如仓储接口要求实体必须带 id:
interface Entity {
id: string;
}
interface Repository<T extends Entity> {
findById(id: string): Promise<T | null>;
save(entity: T): Promise<void>;
}
这样 Repository<OrderSummary> 合法,而 Repository<{ name: string }> 会在编译期被拒绝。泛型的核心不是“写得高级”,而是把类型参数当作契约的一部分,让复用不牺牲约束。
联合类型与类型守卫:把运行时输入收窄为可信类型
服务端最危险的输入来自外部:HTTP 请求体、消息队列消息、第三方回调。这些数据在编译期是 unknown,必须经过校验才能进入业务逻辑。联合类型用来描述“多选一”的状态,类型守卫则负责在运行时判断并收窄。
type PaymentEvent =
| { type: 'paid'; orderId: string; paidAt: string }
| { type: 'refunded'; orderId: string; reason: string }
| { type: 'failed'; orderId: string; errorCode: number };
function isPaidEvent(e: PaymentEvent): e is Extract<PaymentEvent, { type: 'paid' }> {
return e.type === 'paid';
}
function handleEvent(e: PaymentEvent) {
if (isPaidEvent(e)) {
// 此处 e 被收窄为 { type: 'paid'; orderId: string; paidAt: string }
console.log(e.paidAt);
} else if (e.type === 'refunded') {
console.log(e.reason);
} else {
console.log(e.errorCode);
}
}
判别联合(discriminated union)的关键是每个分支都有一个字面量字段作为“判别键”,这里用 type。类型守卫函数返回 e is Extract<...>,编译器就能在 if 分支内把 e 收窄到具体成员。对于来自外部的 unknown,更稳妥的做法是先做结构校验再断言:
function isOrderQuery(input: unknown): input is OrderQuery {
if (typeof input !== 'object' || input === null) return false;
const o = input as Record<string, unknown>;
return typeof o.tenantId === 'string'
&& typeof o.page === 'number'
&& typeof o.pageSize === 'number';
}
注意:类型守卫只保证“形状符合”,不保证业务合法。例如 pageSize 是数字但可能为负数,仍需在守卫之后补业务校验。类型守卫负责类型安全,业务规则负责语义安全,两者不能互相替代。
工具类型:在契约上做派生,避免重复定义
服务端经常需要同一实体的不同视图:创建时没有 id,更新时全部可选,查询时只暴露部分字段。工具类型让这些派生关系显式化:
interface Order {
id: string;
tenantId: string;
amountCents: number;
status: 'pending' | 'paid' | 'shipped' | 'closed';
createdAt: string;
}
type CreateOrderInput = Omit<Order, 'id' | 'createdAt'>;
type UpdateOrderInput = Partial<Pick<Order, 'amountCents' | 'status'>>;
type OrderListItem = Pick<Order, 'id' | 'amountCents' | 'status'>;
type OrderStatus = Order['status'];
Omit 去掉服务端生成的字段,Partial 把更新字段变为可选,Pick 裁剪出列表视图,索引访问类型 Order['status'] 直接复用状态字面量联合。这样当 Order 增加字段时,派生类型会自动跟随,减少遗漏。需要警惕的是 Partial 会让所有字段可选,若更新接口要求至少传一个字段,应额外用联合类型表达“至少一个”的约束,而不是放任空对象通过。
把五者串成一条可维护的契约链
实际落地时,推荐按“外部输入 → 类型守卫 → 接口契约 → 泛型包装 → 工具类型派生”的顺序组织代码。控制器只接收 unknown,先经过守卫函数收窄为具体请求类型,再交给服务层;服务层返回 ApiResponse<T> 或 PageResult<T>,由泛型保证包装结构一致;对外暴露的 DTO 用 Pick、Omit 从领域模型派生,避免手写重复字段。这样做的收益是:字段改名时编译器会指出所有受影响位置,新增状态时联合类型会强制所有分支处理,外部脏数据在进入业务前就被拦截。
as 断言替代类型守卫。对 unknown 直接 as OrderQuery 会绕过编译检查,运行时字段缺失仍会抛错,等于放弃了类型系统的保护。最后需要强调的是,类型系统在服务端的深度应用不是追求类型体操,而是让契约可读、可派生、可校验。接口负责声明形状,泛型负责复用骨架,联合类型负责表达分支,类型守卫负责运行时收窄,工具类型负责派生视图。五者各司其职,才能让 API 契约在编译期和运行期都站得住。