全部文章

TypeScript 类型体操之外:真正提升项目健壮性的四类类型设计

炫技的条件类型很少救过项目,但这四类类型设计救过:判别联合、品牌类型、satisfies 与 as const、以及 Zod 打通的运行时边界。

9 分钟 · 1649 字

我见过不少把 TypeScript 用成「谜题游戏」的代码:一个工具类型套了十二层条件类型,注释写着「看不懂别动」。这类代码确实能让人在评审里收获赞叹,但项目真正出过的线上问题,几乎都不是类型不够花哨造成的,而是状态被允许同时处于两种互斥的情况、两个长得一样的 ID 传反了、外部数据没校验就直接信了。

这篇不讲类型体操,只讲四类我认为有工程价值的类型设计。代码是 ESM 风格。

一、用判别联合建模状态,让不可能状态无法表达

先看一段几乎每个项目都写过的代码:

// 错误写法:字段之间没有约束,可以构造出互相矛盾的状态
interface RequestState<T> {
  loading: boolean;
  data?: T;
  error?: string;
}

const s: RequestState<User[]> = { loading: true, data: [], error: '加载失败' };

这个对象在类型上完全合法,但语义上是荒谬的:既在加载中,又有数据和错误。更现实的问题是,用它渲染页面时你必须写一堆防御判断,而且每个消费方都要自己猜「哪种组合才算数」。

判别联合把状态变成一组互斥的分支:

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function render(state: RequestState<User[]>) {
  switch (state.status) {
    case 'idle':
      return '尚未开始';
    case 'loading':
      return '加载中…';
    case 'success':
      return `${state.data.length} 条记录`;   // 这里 state.data 一定是 T
    case 'error':
      return state.error.message;             // 这里一定有 error
  }
}

好处有三个层次:success 分支里 data 不可能为 undefined;分支写漏了会被编译器发现;新增一个 status 时,所有 switch 都会立刻报错,逼你去处理它。第三点最值钱——它把「未来会漏改」变成了「现在就报错」。

如果担心分支被漏掉,再加一个兜底函数:

function assertNever(value: never): never {
  throw new Error(`unhandled state: ${JSON.stringify(value)}`);
}

// 在 switch 的 default 分支调用:
// default:
//   return assertNever(state);   // 一旦新增分支未处理,这里编译不过

适用场景很明确:请求状态、表单校验结果、消息类型、支付状态机、任何带「阶段」的领域对象。

二、用品牌类型区分长得一样的值

这是我在一个真实 bug 之后才重视的做法。当时一个接口同时接收 userId 和 orderId,两者都是 string 类型,某一个调用点把顺序写反了,类型检查一路绿灯,直到线上出现「用别人的 ID 查订单」。

类型系统无法区分两个都是 string 的值,除非我们主动打上标记:

declare const brand: unique symbol;

type Brand<T, B extends string> = T & { readonly [brand]: B };

export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;

// 只在边界处构造,构造即校验
export const toUserId = (raw: string): UserId => {
  if (!/^u_[0-9a-f]{16}$/.test(raw)) {
    throw new Error(`invalid userId: ${raw}`);
  }
  return raw as UserId;
};

// 订单号由数据库生成,格式固定,这里只做类型标记
export const toOrderId = (raw: string): OrderId => raw as OrderId;

declare function getOrder(userId: UserId, orderId: OrderId): Promise<Order>;

const uid = toUserId(req.params.userId);
const oid = toOrderId(req.params.orderId);
await getOrder(oid, uid);   // 编译错误:OrderId 不能赋给 UserId

同构的「单位」问题也适用。我维护过一个调度模块,函数签名是 schedule(task, delay: number),有人传了 30(以为是秒),实际单位是毫秒,任务延迟了 30 毫秒。加上品牌之后这类错误在编译期就没了:

export type Milliseconds = Brand<number, 'ms'>;
export type Seconds = Brand<number, 's'>;

export const ms = (n: number): Milliseconds => n as Milliseconds;
export const s = (n: number): Seconds => n as Seconds;
export const toMs = (value: Seconds): Milliseconds => (value * 1000) as Milliseconds;

declare function schedule(task: Task, delay: Milliseconds): void;

schedule(task, s(30));           // 编译错误:Seconds 不能赋给 Milliseconds
schedule(task, toMs(s(30)));     // 正确:转换意图写在代码里

代价是每次都要在边界处转换,代码会多一点噪音。所以我的用法是克制的:只给那些「传反了会造成真实事故」的值加品牌——各类 ID、金额与单位、已转义与未转义的字符串。不是所有 string 都值得。

三、用 satisfies 和 as const 留住字面量类型

第三个高频问题是:注解把类型拓宽了,导致后面的代码失去了精确信息。

// 错误写法:注解成 Record<string, string>,key 和 value 都丢了字面量
const ROUTES: Record<string, string> = {
  home: '/',
  blog: '/blog',
  post: '/blog/[slug]',
};

type RouteName = keyof typeof ROUTES;   // string —— 完全没用了

as const 能保住字面量,但它不做校验,写错一个值也没人拦:

const ROUTES = { home: '/', blog: 'blog' } as const;   // 少了斜杠,无人发现

satisfies 解决的就是这个矛盾:校验用注解,类型保留用字面量。

type Routes = Record<string, `/${string}`>;

const ROUTES = {
  home: '/',
  blog: '/blog',
  post: '/blog/[slug]',
} as const satisfies Routes;   // 校验通过,同时保住了 '/' 这样的字面量类型

type RouteName = keyof typeof ROUTES;      // 'home' | 'blog' | 'post'
type HomePath = (typeof ROUTES)['home'];   // '/'

一个更实际的例子是配置对象。加上 satisfies 之后,错误会精确指出是哪个字段不符合:

interface RetryConfig {
  retries: number;
  timeoutMs: number;
  backoff: 'fixed' | 'exponential';
}

const config = {
  retries: 3,
  timeoutMs: 5000,
  backoff: 'exponential',
} as const satisfies RetryConfig;

// config.retries 的类型是 3,而不是 number —— 需要精确值的场景会感谢这一点

这两者的分工我总结成一句话:as const 负责不变,satisfies 负责不宽。

四、用 Zod 把运行时边界和静态类型打通

前三类解决的都是「代码内部」的问题。但真正出问题的数据,绝大多数来自外部:HTTP 请求体、环境变量、第三方接口返回、localStorage。这些数据在类型系统里是被我们「声称」成某个类型的,编译器无法验证。

// 危险写法:类型断言不是校验,只是让编译器闭嘴
const input = req.body as CreateOrderInput;
await orderService.placeOrder(input);   // input.items 可能是 undefined

Zod 让校验和类型来自同一个定义:

import { z } from 'zod';

const CreateOrderSchema = z.object({
  userId: z.string().regex(/^u_[0-9a-f]{16}$/),
  items: z
    .array(z.object({ productId: z.string().min(1), quantity: z.number().int().positive() }))
    .min(1),
  couponCode: z.string().optional(),
});

export type CreateOrderInput = z.infer<typeof CreateOrderSchema>;

app.post('/orders', async (req, res) => {
  const parsed = CreateOrderSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: 'invalid params', details: parsed.error.flatten() });
  }
  // 到这里 parsed.data 已经是收窄后的 CreateOrderInput
  const order = await orderService.placeOrder(parsed.data);
  return res.status(201).json({ id: order.id });
});

关键不在于省了手写 interface 的功夫,而在于「校验什么」和「类型是什么」永远不会漂移。 手写 interface 加手写校验函数的组合,迟早会出现「加了字段忘了校验」的情况。

还能和前面的品牌类型接上:z.string().regex(...).brand<'UserId'>() 可以让解析结果直接就是 UserId,边界处一次性完成校验与定型。

环境变量同样值得这么处理——我在项目里用它替代了「启动时发现 DATABASE_URL 是 undefined,跑到半夜才炸」的经典事故:

const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  PORT: z.coerce.number().int().default(3000),
  DATABASE_URL: z.string().url(),
});

export const env = EnvSchema.parse(process.env);   // 启动即失败,优于运行中失败

什么时候「类型太重」反而是负担

我不想把这四类做法说成无条件的正确答案,它们都有代价:

  1. 品牌类型的转换噪音会累积。 如果每个字符串都打品牌,业务代码里会到处是 toXxx()。我的界限是:会造成数据越权或金额错误的才加。
  2. Zod 不是免费的。 大数组、高频调用路径上做全量 parse 有实际开销,我一般只在系统边界解析一次,内部传递已经收窄的类型。
  3. 类型复杂度要设预算。 一个「三个月后的自己看不懂」的工具类型,就是负资产——它会在报错时吐出两百行类型推导,让排查时间从五分钟变成半小时。判断标准很简单:这个类型是在防止真实发生过的错误,还是在展示技巧。
  4. 内部函数不要层层校验。 边界严、内部松,是我目前觉得最划算的分布。

小结

手法解决的真实问题我的使用频率
判别联合状态可以被构造成互相矛盾高,几乎每个模块
品牌类型同构的 ID / 单位传反中,只给关键值
satisfies + as const注解拓宽导致字面量信息丢失高,配置与常量表
Zod外部输入未校验就被信任高,所有系统边界

类型系统的价值不在于能表达多复杂的东西,而在于能不能让错误在编译期暴露,而不是在凌晨三点的告警里暴露。上面这四类设计,我每一样都能对应到一次真实的、已经发生过的 bug。

本文由 Kyne 撰写,采用 CC BY-NC-SA 4.0 许可,转载请注明出处。