1. 技术难点:为什么类型会被"当摆设"
很多项目虽然用了 TypeScript,但实际是"给已有的 JS 加标注"——先写逻辑,再补类型,甚至用 any 糊过去。这样类型系统形同虚设:
any泛滥:一遇到复杂逻辑就as any跳过,类型检查失去意义。- 类型和实现脱节:先写代码再补类型,类型经常是"事后描述",无法约束行为。
- 改需求崩一片:数据模型变了,调用方不知道,运行时才炸。
- 接口耦合:前后端/跨模块传值,类型没统一,靠文档和猜。
核心难点:如何让类型先行——先用类型精确刻画"数据的形状和约束",再据此实现,从而:
- 编译期就拦下大多数错误(少写、错字段、不该传的都过不了)
- 类型成为团队的"可执行文档"和契约
- 改模型时,编译器告诉你所有要改的地方(而不是漏改)
2. 完整解法:类型驱动开发的五步法
2.1 先定义"领域类型"(类型即契约)
动手写逻辑前,先精确刻画数据模型。不是草率 interface,而是把可选、只读、字面量、联合都用上:
// 用字面量类型 + 联合约束取值范围,杜绝魔法字符串
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled';
type Currency = 'CNY' | 'USD';
// 用 readonly 表达不可变
interface Order {
readonly id: string; // 只读,防误改
amount: number;
currency: Currency;
status: OrderStatus;
items: readonly OrderItem[]; // 数组只读
}
// 用可选 + 明确空态,别用 any/undefined 模糊
interface User {
id: number;
name: string;
email?: string; // 可空但明确
phone: string | null; // null 有语义(未填)vs 缺失
}
关键:类型要能表达"什么不可能发生",这样编译器帮你排除。
2.2 让非法状态"不可表示"(Make Illegal States Unrepresentable)
这是类型驱动开发的精髓——设计类型时,让"错误的组合"根本表达不出来:
// ❌ 反例:status 和 paidAt 可能不一致(pending 也能有 paidAt)
interface BadOrder { status: string; paidAt?: Date; }
// ✅ 用联合类型 + 不同结构,让非法组合类型上报错
type Order =
| { status: 'pending' }
| { status: 'paid'; paidAt: Date; paymentId: string }
| { status: 'shipped'; trackingNo: string };
// 使用时可直接判别(type narrowing),编译器保证分支正确
function describe(o: Order) {
if (o.status === 'paid') {
return `已支付 ${o.paymentId}`; // 这里有 paymentId,编译器保证
}
// o.status === 'pending' 时不可能访问 o.paymentId
}
这样"未支付却有支付记录"这类 bug,在类型层面就不存在。
2.3 先写函数签名,再实现(Signature First)
实现前先把函数类型签名写清楚,头尾都用强类型:
// 先定契约:输入、输出、可能抛错
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
// 实现被签名约束,直接照着写
function processOrder(orderId: string): Promise<Result<Order>> {
// 实现时你很清楚:成功返回 {ok:true,value},失败返回 {ok:false,error}
const order = fetchOrder(orderId);
if (!order) return { ok: false, error: '订单不存在' };
return { ok: true, value: order };
}
Result 模式:用"成功/失败联合"显式表达错误,替代裸的 throw/null,调用方能穷举处理。
2.4 用工具类型驱动"派生"
从基础模型派生不用手写一堆重复类型:
interface User { id: number; name: string; email: string; password: string; }
// 面向用户视图:剔除敏感字段
type PublicUser = Omit<User, 'password'>;
// 创建参数:密码必填,其余可选
type CreateUserDto = Pick<User, 'name' | 'email'> & { password: string };
// 更新参数:全部可选
type UpdateUserDto = Partial<Omit<User, 'id'>>;
2.5 结合运行时校验(类型 + 校验双保险)
类型只存在于编译期(TS 编译后就没了),运行时拿到的数据不一定是类型声明的样子。生产环境要配合运行时校验(zod 之类):
import { z } from 'zod';
// 用 zod 定义 schema(运行时校验 + 推导出 TS 类型)
const OrderSchema = z.object({
id: z.string(),
amount: z.number().positive(),
status: z.enum(['pending', 'paid', 'shipped', 'cancelled']),
});
type Order = z.infer<typeof OrderSchema>; // 从 schema 推导类型
// 运行时:解析外部数据(API 响应/请求体),不合法就抛
const parsed = OrderSchema.parse(apiResponse); // 类型也是 Order
分工:TS 类型管"编译期约束",zod/joi 管"运行时校验",两者用 z.infer 关联,一套 schema 双用,杜绝"类型说有、运行时没有"。
3. 应用场景
| 场景 | 类型驱动价值 |
|---|---|
| 前后端接口传值 | 类型统一契约,改模型编译器提醒所有调用方 |
| 复杂状态机(订单/审批) | 判别联合让非法状态不可表示 |
| 表单/请求体校验 | zod schema 一套双用(类型+运行时) |
| 组件 API 设计 | 强类型 props 约束组件用法 |
| 大型项目重构 | 类型当"编译器帮你找出所有受影响的代码" |
解决的具体问题:
any泛滥、类型形同虚设- 改变量结构漏改调用方(运行时才炸)
- 状态组合非法(未支付却有支付记录)
- 类型和运行时数据不一致
4. 要点总结
- 类型先行:先刻画"数据形状 + 不可能发生的状态",再实现。
- Make Illegal States Unrepresentable:让非法组合在类型层面表达不出来,是类型驱动核心。
- 判别联合 + 字面量:替代 string 魔法值,配合 type narrowing 获得安全分支。
- Signature First:先写函数签名(含 Result 错误模式)再实现。
- 工具类型派生:Omit/Pick/Partial 从基础模型派生视图/参数类型。
- 编译期 + 运行时双保险:TS 类型管编译期,zod 管运行时,
z.infer让一套 schema 双用。 - 别过度设计:小项目/一次性代码不必强上,类型驱动收益在模型复杂、协作多、易变的场景最大。
一句话:类型驱动开发不是"补类型",而是让类型成为契约和护栏——先定义"什么合法、什么不可能",编译器就能在写错时、改需求时、传错数据时帮你拦下,而不是等线上炸。