我把一个商品 ID 错传给了需要用户 ID 的函数,TypeScript 全程绿灯没有半点警告,直到线上查错了数据才暴露,我对着结构化类型让语义不同的类型随意互换这个坑排查了大半天的复盘
这是一个让我对 TypeScript 的"类型系统到底在保护什么"重新认识的坑。它的隐蔽之处在于:我犯的是一个纯粹的逻辑错误(把 A 的 ID 当成 B 的 ID 用了),而我本以为"有类型系统罩着"的 TypeScript,在这种错误面前却完全沉默——它本该是我最可靠的防线,这次却视而不见。
事情起于一个数据查询。我的代码里有"用户 ID"和"商品 ID",它们恰好都是 number 类型。我有一个根据用户 ID 查用户的函数。某次重构时,我不小心把一个商品 ID 传进了这个查用户的函数:
type UserId = number; // 用户ID, 本质是 number
type ProductId = number; // 商品ID, 本质也是 number
function getUserById(id: UserId): User {
return db.users.findById(id);
}
// 业务代码里:
const productId: ProductId = 12345; // 这是一个【商品】ID
// ★★★ 逻辑错误: 把【商品ID】传给了查【用户】的函数 ★★★
const user = getUserById(productId); // 😱 TypeScript 完全没报错! 全程绿灯
// 运行时: 拿 12345 这个"商品ID"去 users 表查, 查出了一个【id恰好是12345的用户】
// → 张冠李戴! 返回了一个【毫不相干的用户】, 而我以为它和那个商品有关
这段代码,编译器一声不吭。getUserById(productId) 这一行,把一个语义上完全是"商品"的 ID,传给了一个明确要求"用户 ID"的函数,而 TypeScript 没有任何警告。运行时,这个商品 ID(比如 12345)被拿去 users 表里查询,恰好查出了一个 id 也是 12345 的、毫不相干的用户——于是一个张冠李戴的、错误的数据就这么悄无声息地流进了业务逻辑。我盯着这行代码,困惑又委屈:我明明给它们起了 UserId 和 ProductId 两个不同的名字,TypeScript 为什么不能帮我区分,任由我把商品 ID 当用户 ID 用?
第一件事:看清真相——TypeScript 是结构化类型,只看结构不看名字
我去深入研究了 TypeScript 的类型系统设计哲学,才终于明白这份"沉默"的根源——TypeScript 采用的是"结构化类型系统(Structural Typing)",它判断两个类型是否兼容,只看它们的"结构"是否匹配,而完全不看它们的"名字"。
结构化类型(Structural Typing)的真相
# 1. 两种类型系统的根本区别:
# - 名义类型(Nominal Typing): 看【名字/声明】。两个类型即使结构一样,
# 只要名字不同, 就是【不同的类型】, 不能互换。(Java/C#/Go 的 class 偏向这种)
# - 结构化类型(Structural Typing): 看【结构】。只要两个类型的结构(成员/形状)
# 兼容, 就认为它们【是兼容的/可互换的】, 完全【不管名字】。
# → TypeScript 采用的是【结构化类型】!
# 2. 我的 UserId 和 ProductId:
# type UserId = number;
# type ProductId = number;
# - 这里的 type 只是给 number 起了个【别名(alias)】, 它们【本质上就是 number】!
# - 它俩的"结构"完全一样(都是 number) → 在结构化类型下, 它们【完全等价、可互换】
# - "UserId""ProductId"这两个名字, 对 TypeScript 的类型检查【毫无意义】,
# 它只看到"哦, 都是 number, 那随便传"。
# 3. 所以 getUserById(productId):
# - getUserById 要 UserId(=number), productId 是 ProductId(=number)
# - 结构一样(都是number) → 类型兼容 → 编译器放行, 不报错!
# - 编译器根本不知道"UserId 和 ProductId 在【业务语义】上是两种东西",
# 它眼里它俩就是同一个 number。
# 4. 为什么 TS 要用结构化类型(它有好处):
# - 灵活: 不用显式声明"实现了某接口", 只要形状对就能用(鸭子类型)
# - 契合 JS 的动态、灵活风格
# → 但代价就是: 它【无法区分"结构相同、语义不同"的类型】(本文的坑)
# 核心: TS是结构化类型, 只看结构不看名字; type别名(UserId=number)只是别名、本质还是number,
# 结构相同就完全互换; 所以"语义不同但结构相同"的类型(商品ID/用户ID)会被当成一回事, 传错不报错。
真相大白,我恍然大悟。原来类型系统有两种流派:"名义类型(Nominal Typing)"看名字(结构一样但名字不同就是不同类型,Java/C# 的 class 偏这种);"结构化类型(Structural Typing)"看结构(只要结构兼容就认为可互换,完全不管名字)——而 TypeScript 采用的是结构化类型。所以我那两行 type UserId = number; type ProductId = number;,这里的 type 只是给 number 起了个别名,它们本质上就是 number;在结构化类型下,它俩结构完全一样(都是 number)、完全等价可互换,而 "UserId" "ProductId" 这两个名字对类型检查毫无意义。于是 getUserById(productId):函数要 UserId(=number),传入 ProductId(=number),结构一样、类型兼容、编译器放行——它根本不知道"UserId 和 ProductId 在业务语义上是两种东西",在它眼里它俩就是同一个 number。结构化类型有它的好处(灵活、契合 JS 的鸭子类型风格),但代价就是:它无法区分"结构相同、语义不同"的类型——这正是我这次的坑。
第二件事:正解——用"品牌类型(Branded Types)"给结构相同的类型加上标识
搞懂了原理,正解就清晰了:给结构相同但语义不同的类型,人为加上一个独一无二的"品牌(brand)"字段,让它们的结构不再相同,从而被 TypeScript 区分开。
// ====== 正解一(推荐): 品牌类型 Branded Types(标称类型模拟) ======
// 用一个"幽灵属性"(实际不存在, 只在类型层面)给类型打上独一无二的烙印
type UserId = number & { readonly __brand: 'UserId' };
type ProductId = number & { readonly __brand: 'ProductId' };
// 现在 UserId 和 ProductId 的"结构"不同了(品牌不同), TS 能区分它们!
function getUserById(id: UserId): User {
return db.users.findById(id);
}
// 创建品牌类型的值(需要一次显式断言, 通常封装成构造函数)
const toUserId = (n: number): UserId => n as UserId;
const toProductId = (n: number): ProductId => n as ProductId;
const productId = toProductId(12345);
// getUserById(productId);
// ❌ 编译错误! Argument of type 'ProductId' is not assignable to 'UserId'
// → ★ TypeScript 现在能拦住这个错误了! 编译期就报错, 而非线上才暴露
const userId = toUserId(999);
getUserById(userId); // ✓ 正确, 类型匹配
// ====== 它的原理 ======
// number & { __brand: 'UserId' } 是个【交叉类型】, 这个 __brand 属性:
// - 实际运行时【根本不存在】(number 上没有这属性), 是纯类型层面的"标记"
// - 但它让 UserId 和 ProductId 在【类型结构上不同】, 于是不能互相赋值
// - 运行时 UserId 仍然就是个 number, 零运行时开销
// ====== 正解二: 用 class 包装(class 在 TS 里有一定名义性) ======
class UserId2 { constructor(public readonly value: number) {} }
class ProductId2 { constructor(public readonly value: number) {} }
// 两个 class 即使结构一样, TS 对带 private/特定成员的 class 会区别对待;
// (但有运行时开销, 且取值要 .value, 不如品牌类型轻量)
// ====== 正解三: 用 enum / 独立的不透明类型库 ======
// 一些库(如 ts-opaque、newtype-ts)提供了更完善的"不透明类型"封装。
// 核心: 对"结构相同但语义不同、绝不能混用"的类型(各种ID、单位、状态码),
// 用品牌类型(number & {__brand})给它们打上类型层面的唯一烙印, 让TS能在编译期区分、拦截混用。
修复的核心,是"用品牌类型给结构相同的类型加上独一无二的标识,让 TS 能区分它们"。正解一(推荐):品牌类型 Branded Types——用交叉类型 number & { readonly __brand: 'UserId' } 给类型打上一个独一无二的"幽灵属性";这个 __brand 属性运行时根本不存在(纯类型层面的标记、零运行时开销),但它让 UserId 和 ProductId 在结构上不同,于是不能互相赋值——把 ProductId 传给 getUserById 会在编译期就报错,而非线上才暴露。创建值时需要一次显式断言,通常封装成 toUserId/toProductId 构造函数。正解二:用 class 包装(class 在 TS 里有一定名义性,但有运行时开销、取值要 .value);正解三:用不透明类型库(ts-opaque、newtype-ts)。归根结底:对"结构相同但语义不同、绝不能混用"的类型(各种 ID、单位、状态码),用品牌类型打上类型层面的唯一烙印,让 TS 能在编译期区分、拦截混用。
第三件事:结构化类型相关的其他表现与坑
排查后我把结构化类型在 TS 里其他常见的表现(有好有坏)也系统梳理了一遍。
结构化类型的其他表现与坑
# 1. 语义不同、结构相同的类型可互换(本文): ID/单位混用不报错。→ 品牌类型。
# 2. 多余属性也能赋值(变量赋值时): 一个有更多属性的对象, 可赋给
# "结构是其子集"的类型变量(只要包含了所需属性)。→ 这是结构化的正常行为。
# (注意: 对象【字面量直接赋值】时有"多余属性检查", 会报错; 经过变量就不查了)
# 3. 空接口/空对象类型 {} 几乎啥都能赋: {} 表示"非null/undefined的任何值"
# → type X = {} 几乎没有约束力, 别用它表示"空对象"。
# 4. 函数参数的双变(bivariance)坑: 方法参数在某些情况下协变, 可能不安全。
# 5. 私有成员让 class 带点名义性: 含 private 字段的 class, 结构判断会考虑私有成员,
# → 所以两个看似一样、但各自有 private 的 class 不能互换(可利用这点做名义区分)。
# 6. 枚举的结构性: 数字 enum 在某些位置可被普通 number 赋值, 不够严格。
# 7. 误以为 TS 是名义类型: 从 Java/C# 来的人常假设"类型名不同就不兼容",
# → 在 TS 里这个假设不成立, 要建立"结构兼容即可互换"的心智模型。
# 共同根源: TS 选择了结构化类型——"长得像就算同类"(鸭子类型), 灵活但牺牲了
# "用名字强制区分语义"的能力; 它保护"结构/形状的正确", 但不天然保护"语义的正确"。
# 核心: TS是结构化类型, 形状兼容即可互换(灵活但不区分语义); 要表达"语义级的不同",
# 需用品牌类型/private成员等手段额外赋予名义性; 别用名义类型的直觉去想TS。
排查让我把结构化类型的其他表现也梳理清了。一、语义不同结构相同的类型可互换(本文)。二、多余属性也能赋值(经过变量时不查多余属性,字面量直接赋值才有多余属性检查)。三、空对象类型 {} 几乎啥都能赋(没约束力)。四、函数参数双变坑。五、私有成员让 class 带名义性(含 private 的 class 结构判断会考虑私有成员,可利用做区分)。六、枚举的结构性。七、误以为 TS 是名义类型(从 Java/C# 来的人常踩)。它们的共同根源是:TS 选择了结构化类型——"长得像就算同类",灵活但牺牲了"用名字强制区分语义"的能力;它保护"结构/形状的正确",但不天然保护"语义的正确"。核心是:要表达"语义级的不同",需用品牌类型/private 成员等手段额外赋予名义性;别用名义类型的直觉去想 TS。下面这张图,是这次 ID 混用不报错的成因与解法:
第四件事:名义类型 vs 结构化类型对照表
这次踩坑后,我把名义类型和结构化类型的差异整理成一张表,理解 TS 的类型行为时对照。
| 维度 | 名义类型(Java/C#) | 结构化类型(TypeScript) |
|---|---|---|
| 判断兼容看什么 | 看名字/声明 | 看结构/形状 |
| 结构同名字不同 | 不兼容, 不能互换 | 兼容, 可互换(本文坑) |
| 实现接口 | 要显式 implements | 形状对就算实现(隐式) |
| 灵活性 | 较低, 但语义清晰 | 高, 契合JS鸭子类型 |
| 区分语义同型类型 | 天然能(名字不同即不同) | 不能, 需品牌类型模拟 |
| Mock/测试 | 常要实现完整接口 | 给个形状对的对象即可 |
这张表把两种类型系统的取舍讲清了。核心是:名义类型靠"名字"区分(语义清晰、天然能区分同型类型,但不灵活、要显式声明关系);结构化类型靠"形状"区分(灵活、契合鸭子类型、Mock 方便,但无法天然区分语义相同形状的类型);它们各有取舍,没有绝对优劣。它给我的最大启发是:编程语言的每一个核心设计选择(类型系统、内存管理、并发模型……),几乎都是一种"权衡"——它在获得某些优点的同时,必然要付出某些代价;不存在"只有优点没有缺点"的设计。TS 选择结构化类型,获得了"灵活、贴合 JS"的巨大优点,代价就是"无法天然区分语义相同形状的类型"(我这次的坑)。这让我形成一个更成熟的认知:真正理解一门语言/工具,不仅要知道它"能做什么、擅长什么",更要理解它"为了这些优点,做出了什么权衡、放弃了什么、因此有什么固有的局限";只有理解了这些"权衡的另一面",你才能预判它的短板、并在短板处主动补强(比如知道 TS 的结构化局限后,在需要区分语义时主动上品牌类型)。理解一个设计的"权衡的两面"、并在它的局限处主动补强——是从"知道怎么用"到"深刻理解一门语言"的标志。
第五件事:类型系统能保护什么、不能保护什么
这次最深的反思,是关于"类型系统的能力边界"。我把它能与不能保护的整理了一下。
| 类型系统 | 能保护(它擅长的) | 不能天然保护(需额外手段) |
|---|---|---|
| 类型不匹配 | ✓ 把string传给要number的 | — |
| 属性/方法不存在 | ✓ 访问对象没有的属性 | — |
| null/undefined | ✓(开strict) | — |
| 语义混用(本文) | ✗ 结构相同语义不同 | 品牌类型 |
| 业务逻辑正确 | ✗ 算错了金额 | 测试/业务校验 |
| 数值范围/约束 | ✗ 年龄是负数 | 运行时校验/精化类型 |
| 外部数据真实结构 | ✗ API返回不符 | 运行时校验(zod等) |
这张表划清了类型系统的能力边界。核心是:类型系统擅长保护"类型/形状层面的正确"(类型不匹配、属性不存在、空值),但不能天然保护"语义、业务逻辑、数值约束、外部数据真实性"这些更高层、更具体的正确性;这些需要品牌类型、测试、运行时校验等额外手段来补充。它给我的深刻启发是:类型系统是一道强大的防线,但它不是万能的、有明确的能力边界;那种"只要 TypeScript 不报错,代码就是对的"的想法,是一种危险的过度信赖;把"编译通过"等同于"逻辑正确",会让你对类型系统管不到的那一大片区域(业务逻辑、语义、运行时数据)放松警惕。这让我对"各种保障手段"有了更体系化的认识:软件的正确性,需要多层防线协同来保障——类型系统守住"类型/形状",单元测试守住"逻辑行为",运行时校验守住"外部数据",代码审查守住"设计意图"……;每一层都有它的擅长和盲区,没有任何一层能包打天下;成熟的工程,是清楚地知道"每道防线能挡住什么、挡不住什么",并用合适的手段把各个盲区补上,而不是把所有信任都押在某一道防线上。清楚类型系统的能力边界、用多层防线协同守护正确性——是这个语义混用的坑,教给我的关于"如何系统性地保障软件质量"的更宏观的一课。
第六件事:定义类型时,我现在的判断习惯
现在每当我定义一个类型(尤其是各种 ID、标识符),我都会按这张图先想清楚:
这张图的精髓,是"结构相同、语义不同、绝不能混用的类型,必须用品牌类型"。结构不同 TS 本就能区分;结构相同(都是 number/string)但语义不同且绝不能混用(各种 ID、单位、token)的,必须用品牌类型 number & {__brand:'X'},并封装 toX 构造函数集中做断言。最好在数据进入系统的边界处(从 DB/API 读入时)统一打品牌。这套习惯,让我定义类型时,从"用 type 别名图个名字好看(其实没区分作用)"变成了"判断要不要真正的类型级区分、要就上品牌类型"——核心始终是:TS 看结构不看名字,type 别名不防混用;绝不能混的同型类型用品牌类型,让编译期拦住语义错误。
我立下的几条规矩
这场"商品 ID 当用户 ID 用"的事故,换来了我写 TypeScript 时,刻进骨子里的几条铁律:
- TS 是结构化类型,只看结构不看名字。这是理解 TS 一切类型行为的根基。
- type 别名不创造新类型。type UserId = number 还是 number,不防混用。
- 绝不能混用的同型类型用品牌类型。number & {__brand:'X'},零运行时开销。
- 品牌类型封装构造函数。toUserId(n) 集中做断言,在边界处打品牌。
- 别用名义类型的直觉想 TS。名字不同不代表类型不同。
- 类型系统不保护语义和业务逻辑。那些靠测试、运行时校验补。
- 编译通过 ≠ 逻辑正确。多层防线协同,别全押在类型系统上。
附:一套可复用的品牌类型工具与边界打标实践
这次踩坑后,我把品牌类型抽象成了一套可复用的小工具,并形成了"在数据边界处统一打品牌"的实践,新项目直接拿来用:
// ====== 1. 一个通用的 Brand 工具类型 ======
// 给任意基础类型 T 打上名为 B 的品牌
type Brand<T, B extends string> = T & { readonly __brand: B };
// 用它定义各种语义ID(一行一个, 清晰):
type UserId = Brand<number, 'UserId'>;
type ProductId = Brand<number, 'ProductId'>;
type OrderId = Brand<string, 'OrderId'>; // string 也能打品牌
type Email = Brand<string, 'Email'>;
type Cents = Brand<number, 'Cents'>; // 金额(分), 别和"元"混
// ====== 2. 配套的构造函数(可附带运行时校验, 一举两得) ======
const UserId = (n: number): UserId => n as UserId;
const ProductId = (n: number): ProductId => n as ProductId;
const Email = (s: string): Email => {
if (!/^[^@]+@[^@]+$/.test(s)) throw new Error('非法邮箱: ' + s); // ★ 顺便运行时校验!
return s as Email; // 校验通过, 才打上 Email 品牌 → 类型即"已验证的邮箱"
};
// ====== 3. 关键实践: 在"数据边界"统一打品牌 ======
// 从数据库/API 读入数据时, 在这一层就把裸的 number/string 转成品牌类型;
// 此后整个系统内部, 流动的都是带品牌的、不会混用的类型。
function rowToUser(row: any): { id: UserId; name: string } {
return { id: UserId(row.id), name: row.name }; // 在入口处打品牌
}
// ====== 4. 效果: 全程类型安全, 混用即编译错 ======
function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }
const uid = UserId(1);
const pid = ProductId(2);
getUser(uid); // ✓
// getUser(pid); // ❌ 编译错误! ProductId 不能传给要 UserId 的函数
// const bad: Cents = 100; // ❌ 编译错误! 裸number不能直接当Cents, 必须 Cents(100)
// 核心: 用通用Brand工具一行定义各种语义类型, 配构造函数(可顺带运行时校验),
// 在数据进入系统的边界处统一打品牌; 此后内部流动的全是不会混用的强类型。
这套品牌类型工具与实践,是我这次踩坑后最有价值的工程沉淀。它把"防止语义混用"从"靠开发者自觉、靠命名提醒"(不可靠),升级成了"由类型系统在编译期强制保障"(可靠);而一个通用的 Brand<T, B> 工具类型,让定义各种语义 ID 变得像写一行别名一样简单,几乎没有增加心智负担。更妙的是构造函数能"一举两得":在 Email(s) 这样的构造函数里顺手做运行时校验(格式不对就抛错),校验通过才打上 Email 品牌——于是 Email 这个类型从此就不仅意味着"是个字符串",更意味着"是个已经被验证过的合法邮箱"。而最关键的实践,是"在数据边界处统一打品牌":在数据从数据库、API 进入系统的那一层,就把裸的 number/string 转成品牌类型;此后整个系统内部流动的,全是带品牌的、不会混用的强类型。这正是我想分享的核心思想:类型,不应只是"对数据形状的描述",更可以是"对数据所携带的语义、约束、甚至'它经历过什么(如已校验)'的编码";当你把越来越多的"关于这个值的真相"编码进它的类型里,类型系统就能为你守护越来越多的正确性,把越来越多本会在运行时爆发的错误,拦截在编译期。让类型承载语义、在边界处建立强类型、用类型把"已验证"等约束固化下来——这,是我用一次 ID 混用的事故,换来的、关于"如何让类型系统为你做更多事"的实用进阶心法。
写在最后
回头看,这场由"两个都是 number 的 ID"引发的、张冠李戴查错数据的事故,真正教给我的,远不止"用品牌类型"这一个技巧。它让我对"类型系统的本质与边界",以及"如何主动地用类型表达业务约束",有了一次深刻的体会。我栽跟头,根源是我对类型系统抱有一种"想当然的、过度的信赖":我以为只要我给两个东西起了不同的名字(UserId、ProductId),TypeScript 就"应该"能理解它们是不同的、并阻止我混用它们。可我没有意识到,TypeScript 是结构化的——它根本不在乎我起的名字,只看结构;我那两个名字,只是给我自己看的注释,对编译器而言它们就是同一个 number。我把"我心里知道它们不同"误当成了"类型系统也知道它们不同"。这让我领悟到一个深刻的认知:类型系统能为你提供多强的保护,取决于你把多少"关于正确性的信息和约束"真正地、用类型系统听得懂的方式表达给了它;如果你心里的约束(这两个 ID 不能混)只停留在"命名"和"心知肚明"的层面,而没有用类型系统的机制(品牌类型)真正编码进类型里,那么类型系统就无从知晓、也无法帮你守护这个约束。这其实揭示了用好类型系统的核心心法:类型系统不是一个"会自动读懂你意图"的魔法,而是一个"你表达多少约束、它就检查多少约束"的工具;真正强大的类型设计,是主动地、尽可能多地把业务规则和约束"编码进类型"——让"非法的状态变得不可表示(make illegal states unrepresentable)",让那些本不该发生的错误,在编译期就因为"类型对不上"而被拦下。从"被动期待类型系统读懂我"到"主动把约束编码进类型让它替我守护"——这,是我用一次 ID 混用的事故,换来的、关于 TypeScript、也关于如何真正驾驭类型系统的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次定义两个"恰好都是 number 却绝不能混"的 ID 时,顺手就给它们打上品牌,那我对着那条张冠李戴的数据排查的这大半天,就值了。
—— 别看了 · 2026