H5 TypeScript 领域类型设计
H5 TypeScript 领域类型设计
TypeScript 的价值不仅是给变量补充类型标注,更重要的是把业务约束放进类型模型,让错误状态难以被构造。若所有接口都使用宽泛对象、字符串和可选字段,编译器无法帮助识别状态冲突,代码最终仍要依赖大量运行时判断。
一、避免用 string 表达所有概念
订单编号、用户编号和商品编号在运行时都是字符串,但业务含义完全不同。可以使用品牌类型减少误传:
type Brand<T, Name extends string> = T & {
readonly __brand: Name;
};
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
function loadOrder(orderId: OrderId): Promise<Order> {
return orderRepository.load(orderId);
}
品牌类型只在编译阶段提供区分。外部字符串不能直接断言为业务标识,应先经过格式或来源校验,再由统一函数构造。
二、用联合类型表达互斥状态
type PaymentFailure =
| { type: 'balanceInsufficient' }
| { type: 'permissionDenied' }
| { type: 'temporarilyUnavailable' };
type PaymentState =
| { status: 'idle' }
| { status: 'submitting'; requestId: string }
| { status: 'succeeded'; receipt: Receipt }
| { status: 'failed'; reason: PaymentFailure };
这比 isLoading、isSuccess、error 三个独立字段更安全,因为不会出现加载中同时成功的矛盾组合。
渲染时编译器会根据判别字段缩小类型:
function renderPayment(state: PaymentState): string {
switch (state.status) {
case 'idle':
return '等待提交';
case 'submitting':
return '正在提交';
case 'succeeded':
return `已完成:${state.receipt.displayNumber}`;
case 'failed':
return getFailureMessage(state.reason);
}
}
三、使用 never 做穷尽检查
业务状态新增分支时,希望编译器提示所有未更新位置:
function assertNever(value: never): never {
throw new Error(`未处理状态:${String(value)}`);
}
function getFailureMessage(failure: PaymentFailure): string {
switch (failure.type) {
case 'balanceInsufficient':
return '余额不足';
case 'permissionDenied':
return '当前账户无法执行此操作';
case 'temporarilyUnavailable':
return '服务暂时不可用';
default:
return assertNever(failure);
}
}
运行时错误只是最后防线,主要价值是编译阶段要求开发者补全新分支。
四、外部数据从 unknown 开始
类型断言不会验证真实数据。接口响应、本地存储和跨窗口消息都属于外部输入,应先视为 unknown。
type UserSummary = {
id: UserId;
nickname: string;
};
function parseUserSummary(value: unknown): UserSummary {
if (typeof value !== 'object' || value === null) {
throw new Error('用户数据格式错误');
}
const record = value as Record<string, unknown>;
if (typeof record.id !== 'string') {
throw new Error('用户标识格式错误');
}
if (typeof record.nickname !== 'string') {
throw new Error('用户昵称格式错误');
}
return {
id: record.id as UserId,
nickname: record.nickname
};
}
实际项目应优先复用现有解析或校验组件。协议明确为必填的字段直接严格校验,不要把所有字段都做成可选来掩盖接口问题。
五、区分缺失、空值和空字符串
undefined、null 和空字符串可能代表不同业务含义。更新资料时,字段缺失可能表示“不修改”,null 可能表示“清除”,空字符串则可能是非法输入。
type UpdateProfileCommand = {
nickname?: string;
introduction?: string | null;
};
只有服务协议明确区分这些含义时才这样建模。前后端应对每种情况达成一致,不要在请求前随意把所有空值删除。
六、用映射类型表达变化
创建对象和更新对象通常具有不同必填规则。可以在稳定基础类型上构造输入类型,但不要追求过度抽象。
type Product = {
id: string;
name: string;
priceInCent: number;
enabled: boolean;
};
type ProductDraft = Omit<Product, 'id'>;
type ProductPatch = Partial<Pick<Product, 'name' | 'priceInCent' | 'enabled'>>;
ProductPatch 明确只能修改允许字段,避免把标识意外放进更新命令。
七、泛型应表达真实关系
泛型适合描述输入与输出之间的类型联系:
type Page<T> = {
items: T[];
page: number;
hasMore: boolean;
};
interface Repository<Id, Entity> {
load(id: Id): Promise<Entity>;
}
如果泛型参数只出现一次,或调用者必须传递多个无法理解的类型参数,抽象可能没有带来价值。业务代码的可读性优先于类型技巧。
八、只读类型减少意外修改
type ReadonlyCart = {
readonly id: string;
readonly items: readonly CartItem[];
};
只读是编译期约束,不会自动冻结运行时对象,但它能让状态更新入口更明确。配合不可变更新,可以避免组件直接修改共享数组。
九、不要滥用 any 和类型断言
any 会关闭类型检查,并沿调用链扩散。临时接入未知数据时优先使用 unknown,在边界处缩小类型。
类型断言只适用于开发者掌握了编译器无法推导的信息,不能替代运行时校验。连续进行多次断言通常意味着模型或边界设计需要调整。
十、让类型跟随业务语言
类型名、字段名和状态分支应使用团队共同理解的业务术语。不要把协议层缩写直接扩散到界面层,也不要为同一概念建立多个含义接近的类型。
重构时可以先从高风险状态开始:支付、权限、登录和异步流程最适合使用可辨识联合。小型纯展示对象则保持简单即可。
总结
TypeScript 领域建模的目标是让非法状态更难出现。通过品牌类型区分业务标识,用联合类型表达互斥状态,从 unknown 校验外部输入,并用只读与精确更新类型限制修改范围,编译器才能真正参与业务正确性保障。
- 点赞
- 收藏
- 关注作者
评论(0)