TypeScript:使用可辨识联合避免笛卡尔积爆炸
Edit: Figured it out, see my answer in the answer section
嘿,我来给你好好捋捋怎么用可辨识联合解决你遇到的这个问题。首先对齐下你的场景:你现在有个Item接口,里面堆了一堆可选属性,但这些属性根本不是真的可选——它们是跟着item类型枚举走的,不同类型的item只需要对应自己那组专属字段。结果用可选属性搞到最后,TypeScript允许各种不合法的属性组合(也就是大家说的笛卡尔积爆炸),这可太闹心了对吧?
先给你搭个基础场景,假设你已经有了这样的item类型枚举:
enum ItemType { Book = "BOOK", Electronic = "ELECTRONIC", Clothing = "CLOTHING" }
之前你可能写的Item接口大概是这样的:
interface Item { id: string; type: ItemType; // 这些可选属性就是问题根源 author?: string; // 只有Book类型需要 price?: number; // 只有Electronic类型需要 size?: string; // 只有Clothing类型需要 }
这种写法的坑在哪?比如你能创建一个type: ItemType.Book但同时带size属性的对象,TypeScript连个警告都没有,但这明显不符合你的业务逻辑啊!
那用可辨识联合怎么改?核心思路就是:给每种类型的item单独定义接口,然后把它们全部联合起来,每个接口都共享那个作为"辨识符"的type字段——而且这个字段的值在每个接口里是唯一的(就用枚举值就行)。
直接上代码示例:
// 先抽个公共基础接口,把所有item都有的属性放这,避免重复代码 interface BaseItem { id: string; createdAt: Date; } // 给每种item类型写专属接口,只放自己需要的必填属性 interface BookItem extends BaseItem { type: ItemType.Book; author: string; // 这里不用可选,因为Book必须有作者 pages: number; } interface ElectronicItem extends BaseItem { type: ItemType.Electronic; price: number; warrantyMonths: number; } interface ClothingItem extends BaseItem { type: ItemType.Clothing; size: string; material: string; } // 最后把这些专属接口联合起来,这就是我们的安全类型 type Item = BookItem | ElectronicItem | ClothingItem;
这下就舒服多了!当你处理Item类型的变量时,TypeScript会自动根据type字段做类型窄化——比如当你判断item.type === ItemType.Book时,TypeScript就知道这是个BookItem,你可以直接访问author和pages,完全不用担心属性不存在的问题,而且它会直接阻止你访问不属于BookItem的属性(比如size)。
给你看看使用时的代码,感受下类型安全:
function handleItem(item: Item) { switch (item.type) { case ItemType.Book: console.log(`书籍:作者${item.author},共${item.pages}页`); break; case ItemType.Electronic: console.log(`电子产品:售价$${item.price},保修${item.warrantyMonths}个月`); break; case ItemType.Clothing: console.log(`服饰:尺码${item.size},面料${item.material}`); break; default: // 加个默认分支做穷尽检查,防止以后加了新的ItemType却忘了处理 const _exhaustiveCheck: never = item; throw new Error(`未知的item类型:${_exhaustiveCheck}`); } }
哦对了,你提到还有另一个额外的区分维度(比如item的库存状态,比如InStock和OutOfStock)?这也完全没问题,把这个维度也做成辨识符就行。比如给每种item类型再加上状态的专属接口:
enum ItemStatus { InStock = "IN_STOCK", OutOfStock = "OUT_OF_STOCK" } interface InStockBookItem extends BaseItem { type: ItemType.Book; status: ItemStatus.InStock; author: string; pages: number; stockCount: number; // 只有在售的书才需要库存数 } interface OutOfStockBookItem extends BaseItem { type: ItemType.Book; status: ItemStatus.OutOfStock; author: string; pages: number; restockDate: Date; // 只有缺货的书才需要补货日期 } // 最后把所有合法的组合都加入联合类型 type Item = InStockBookItem | OutOfStockBookItem | InStockElectronicItem | OutOfStockElectronicItem;
这样一来,TypeScript会彻底杜绝所有不合法的属性组合——比如你不可能创建一个"在售的书"却带restockDate的对象,完全从类型层面把问题掐死了,再也不会出现笛卡尔积爆炸的情况。
总结下关键要点:
- 每个联合成员都要有一个共同的辨识字段(比如
type、status),字段的值必须唯一 - 每个成员只包含自己业务上需要的必填属性,别搞多余的可选属性
- 利用TypeScript的类型窄化特性(比如switch判断辨识字段),安全访问对应属性
- 可以加个默认分支做穷尽检查,以后加新的类型时不会漏处理
备注:内容来源于stack exchange,提问作者Curtis6566

