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 会让所有字段可选,若更新接口要求至少传一个字段,应额外用联合类型表达“至少一个”的约束,而不是放任空对象通过。

API 契约的类型落地链路 外部输入 HTTP Body / MQ / 回调 编译期 unknown 类型守卫 isOrderQuery(input) input is OrderQuery 接口契约 OrderQuery / Order 字段与可选性约束 业务处理 类型安全 语义校验 泛型:统一响应与分页骨架 ApiResponse<T> code / message / data PageResult<T> items / total / page Repository<T extends Entity> findById / save 判别联合:PaymentEvent paid refunded failed 判别键 type 收窄到具体分支 工具类型派生视图 Omit<Order, 'id' | 'createdAt'> Partial<Pick<Order, 'amountCents' | 'status'>> Pick<Order, 'id' | 'amountCents' | 'status'>

把五者串成一条可维护的契约链

实际落地时,推荐按“外部输入 → 类型守卫 → 接口契约 → 泛型包装 → 工具类型派生”的顺序组织代码。控制器只接收 unknown,先经过守卫函数收窄为具体请求类型,再交给服务层;服务层返回 ApiResponse<T> 或 PageResult<T>,由泛型保证包装结构一致;对外暴露的 DTO 用 Pick、Omit 从领域模型派生,避免手写重复字段。这样做的收益是:字段改名时编译器会指出所有受影响位置,新增状态时联合类型会强制所有分支处理,外部脏数据在进入业务前就被拦截。

类型守卫只做形状校验,业务规则(如金额上限、状态流转合法性)仍需单独实现。把两者混在一起,会让守卫函数承担过多职责,也难以复用。
不要用 as 断言替代类型守卫。对 unknown 直接 as OrderQuery 会绕过编译检查,运行时字段缺失仍会抛错,等于放弃了类型系统的保护。

最后需要强调的是,类型系统在服务端的深度应用不是追求类型体操,而是让契约可读、可派生、可校验。接口负责声明形状,泛型负责复用骨架,联合类型负责表达分支,类型守卫负责运行时收窄,工具类型负责派生视图。五者各司其职,才能让 API 契约在编译期和运行期都站得住。