TypeScript 类型体操之外:真正提升项目健壮性的四类类型设计
炫技的条件类型很少救过项目,但这四类类型设计救过:判别联合、品牌类型、satisfies 与 as const、以及 Zod 打通的运行时边界。
我见过不少把 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); // 启动即失败,优于运行中失败
什么时候「类型太重」反而是负担
我不想把这四类做法说成无条件的正确答案,它们都有代价:
- 品牌类型的转换噪音会累积。 如果每个字符串都打品牌,业务代码里会到处是
toXxx()。我的界限是:会造成数据越权或金额错误的才加。 - Zod 不是免费的。 大数组、高频调用路径上做全量
parse有实际开销,我一般只在系统边界解析一次,内部传递已经收窄的类型。 - 类型复杂度要设预算。 一个「三个月后的自己看不懂」的工具类型,就是负资产——它会在报错时吐出两百行类型推导,让排查时间从五分钟变成半小时。判断标准很简单:这个类型是在防止真实发生过的错误,还是在展示技巧。
- 内部函数不要层层校验。 边界严、内部松,是我目前觉得最划算的分布。
小结
| 手法 | 解决的真实问题 | 我的使用频率 |
|---|---|---|
| 判别联合 | 状态可以被构造成互相矛盾 | 高,几乎每个模块 |
| 品牌类型 | 同构的 ID / 单位传反 | 中,只给关键值 |
satisfies + as const | 注解拓宽导致字面量信息丢失 | 高,配置与常量表 |
| Zod | 外部输入未校验就被信任 | 高,所有系统边界 |
类型系统的价值不在于能表达多复杂的东西,而在于能不能让错误在编译期暴露,而不是在凌晨三点的告警里暴露。上面这四类设计,我每一样都能对应到一次真实的、已经发生过的 bug。
本文由 Kyne 撰写,采用 CC BY-NC-SA 4.0 许可,转载请注明出处。