我把 productId 当成 userId 传进了查询函数,两个都是 number、TypeScript 一声没吭,直到线上查出了张冠李戴的数据:一次 TS 结构化类型让语义不同的 ID 意外兼容的深度复盘
那个 bug 是数据"张冠李戴"才暴露的:我们有两个函数,getUser(userId: number) 和 getProduct(productId: number)。一次重构里,我在一段代码里手滑,把一个 productId 传给了 getUser()。我满以为 TypeScript 会用类型检查替我拦下这种低级错误——可它一声没吭、编译通过。结果线上就出现了诡异的数据:用一个商品 ID 去查用户,查出了一个 ID 恰好等于那个商品 ID 的、完全不相干的用户(或查不到),业务逻辑全乱了。我盯着那行"明明传错了类型却不报错"的代码,起初以为是 TS 的 bug,查到底才看明白,后背发凉:这不是 bug,而是 TypeScript 的一个核心设计——"结构化类型系统(structural typing)"。它判断"两个类型是否兼容",不看它们"叫什么名字",只看它们"结构(形状)是否一样"。而我的 userId 和 productId,虽然语义完全不同,但它们的"结构"是完全一样的——都是 number;在 TS 眼里,"一个 number" 和 "另一个 number" 就是同一种类型、完全可以互换,它根本无从知道"这个 number 是用户 ID、那个 number 是商品 ID,不该混用"。问题的根,是 TS 的结构化类型只按"结构"判断兼容性;userId 和 productId 结构都是 number、结构相同,所以被认为可互换,我传错了它也不报错。这篇就把这次"结构化类型让语义不同的类型意外兼容"的坑,从头到尾复盘一遍。
故障现场:两个语义不同的 ID 被随意互换
问题在于 userId 和 productId 结构都是 number,TS 结构化类型认为它们可互换:
// ✗ 出问题的代码: 两个语义不同的ID, 类型都是 number
function getUser(userId: number) { /* 按userId查用户 */ }
function getProduct(productId: number) { /* 按productId查商品 */ }
const productId: number = 12345;
// 手滑: 把 productId 传给了 getUser
getUser(productId); // ✗ TS【不报错】! 因为 productId 和 userId 类型都是 number, 结构相同
// 现象: 编译通过, 运行时用商品ID去查用户 → 查出张冠李戴的数据 / 查不到, 业务逻辑乱掉。
// 为什么TS不报错? 因为TS是"结构化类型系统(structural typing)":
// - 它判断"两个类型兼不兼容", 只看【结构(形状)】是否匹配, 不看【名字/来源】;
// - userId参数要的是number, productId也是number → 结构完全相同 → 视为兼容、可互换;
// - TS【无法知道】"这个number代表用户、那个number代表商品", 在它眼里都只是number。
// 同样的坑在对象上更隐蔽:
interface User { id: number; name: string; }
interface Product { id: number; name: string; } // 结构和User一模一样!
function saveUser(u: User) { /* ... */ }
const p: Product = { id: 1, name: "手机" };
saveUser(p); // ✗ 不报错! User和Product结构相同 → TS认为Product兼容User, 可传入。
// 对比: 有些语言是"名义类型(nominal typing)", 类型按"名字"区分,
// 即使结构相同, User和Product也是不同类型, 不能互换 → 能拦住这种错。
// 但TS选择了结构化类型(更灵活、利于鸭子类型), 代价就是"结构相同则兼容", 拦不住语义混淆。
// 关键: TS是结构化类型系统, 按"结构是否相同"判断兼容; 语义不同但结构相同的类型(如都是number的
// userId/productId)会被认为可互换, 传错了也不报错 —— 类型相同不代表语义相同。
第一次理解"原来 TS 只看结构、不看这 number 是啥含义"时,我又懊恼又恍然:"我一直以为类型系统能帮我区分'用户 ID'和'商品 ID',完全没想到在 TS 眼里它们都只是 number、可以随便互换。"这个坑最隐蔽的地方在于:它恰恰发生在你最信赖类型检查的时候——你以为"类型对了就安全了",可"类型(结构)对了"不代表"语义对了";TS 拦得住"把 string 传给 number"(结构不同),却拦不住"把商品 ID 传给用户 ID"(结构相同、语义不同)。下面就来拆解,结构化类型到底是怎么回事、怎么让 TS 帮我们区分语义。
第一件事:搞懂结构化类型 vs 名义类型
我顺着这次事故,把 TypeScript 的结构化类型系统彻底理清了。
TypeScript 的结构化类型(structural typing)是什么?为什么拦不住语义混淆?
【核心: TS按"结构是否相同"判断类型兼容(不看名字); 语义不同但结构相同的类型被视为可互换; 想区分语义要用"品牌类型"造出结构差异】
1. 两种类型系统的判别方式:
- 名义类型(nominal, 如Java/C#的class): 类型按【名字/声明】区分;
即使结构相同, 名字不同就是不同类型, 不能互换 → 能区分userId和productId;
- 结构化类型(structural, TS): 类型按【结构(有哪些属性、什么类型)】判断兼容;
只要结构匹配, 名字不同也视为兼容 → userId和productId都是number, 视为可互换。
2. TS 为什么选结构化类型?
- 更灵活、契合JS的"鸭子类型"("长得像就是")文化;
- 不用显式声明"实现了某接口", 只要结构对上就兼容 → 写起来方便、组合性强;
- 代价: "结构相同即兼容", 无法区分"结构相同但语义不同"的类型。
3. 本文的坑: 结构相同 → 语义混淆拦不住
- userId: number 和 productId: number, 结构都是number → TS视为同一类型, 可互换;
- User 和 Product 若结构相同, 也互相兼容;
- → TS拦得住"结构不同"(string vs number)的错, 拦不住"结构相同、语义不同"的错。
4. 怎么让结构化类型"区分"语义? —— 制造结构上的差异(品牌类型/Branded Types)
- 既然TS只看结构, 那就给不同语义的类型【人为加上一点不同的结构(品牌标记)】, 让它们结构不同;
- 用一个"幽默的"交叉类型加一个不存在的品牌属性:
type UserId = number & { readonly __brand: 'UserId' };
type ProductId = number & { readonly __brand: 'ProductId' };
- 现在 UserId 和 ProductId 结构不同(品牌不同) → TS就能区分、传错会报错!
- (这个__brand属性只存在于类型层面, 运行时不存在, 不影响实际值。)
5. 何时需要品牌类型?
- 当"多个语义不同、但底层类型相同(都是number/string)的值"容易被混淆、且混淆后果严重时;
如各种ID、金额(分/元)、单位(米/英尺)、不同坐标系等;
- 不是所有number都要包, 别过度; 对"易混淆且混了出大事"的关键值用。
一句话: TS是结构化类型(按结构判断兼容, 不看名字), 故语义不同但结构相同的类型(都是number的各种ID)会被
视为可互换、传错不报错; 想让TS区分这些语义, 用"品牌类型"给它们制造结构差异。
这套认知,是整个坑的根。两种类型系统:名义类型(Java/C#)按"名字"区分,结构相同名字不同也是不同类型;结构化类型(TS)按"结构"判断兼容,结构匹配名字不同也兼容。TS 为什么选结构化:更灵活、契合 JS 鸭子类型、组合性强;代价是无法区分结构相同但语义不同的类型。本文的坑:userId 和 productId 结构都是 number→视为可互换;TS 拦得住结构不同(string vs number)、拦不住结构相同语义不同的错。怎么区分语义——品牌类型(Branded Types):既然 TS 只看结构,就给不同语义的类型人为加上不同的品牌标记造出结构差异:type UserId = number & { readonly __brand: 'UserId' },现在传错会报错(__brand 只在类型层面、运行时不存在)。何时用:多个语义不同但底层类型相同的值容易混淆且后果严重时(各种 ID、金额、单位);别过度,对关键值用。一句话:TS 是结构化类型(按结构判断兼容,不看名字),故语义不同但结构相同的类型(都是 number 的各种 ID)会被视为可互换、传错不报错;想让 TS 区分这些语义,用品牌类型给它们制造结构差异。
第二件事:正解——用品牌类型(Branded Types)给语义不同的 ID 制造结构差异
搞懂了原理,正解就清晰了:给容易混淆的、语义不同的 ID(或值)定义"品牌类型",人为加上不同的品牌标记制造结构差异,让 TS 能区分它们、传错就报错。
// ====== 正解: 用品牌类型(Branded Types)区分语义不同的ID ======
// 1. 定义品牌类型: number 交叉一个"幽灵"品牌属性(只在类型层面存在)
type UserId = number & { readonly __brand: 'UserId' };
type ProductId = number & { readonly __brand: 'ProductId' };
// 2. 提供构造函数, 把裸number"标记"成对应的品牌类型(运行时就是原值, 零开销)
function toUserId(n: number): UserId { return n as UserId; }
function toProductId(n: number): ProductId { return n as ProductId; }
// 3. 函数参数用品牌类型, 而非裸number
function getUser(userId: UserId) { /* ... */ }
function getProduct(productId: ProductId) { /* ... */ }
// 现在: 传错就【编译报错】!
const productId = toProductId(12345);
getUser(productId); // ✗ 编译报错! ProductId 不能赋给 UserId(品牌不同, 结构不同)
getUser(toUserId(67890)); // ✓ 正确: 明确标记为UserId才能传
// → 关键: __brand 让 UserId 和 ProductId 在【结构上不同】, TS的结构化类型就能区分它们;
// 而 __brand 只存在于类型系统(运行时number还是number), 不影响实际运行、零运行时开销。
// ====== 应用: 不止ID, 各种"易混淆的同底层类型值" ======
// 金额: 分 vs 元(混了就差100倍!)
type Cents = number & { readonly __brand: 'Cents' };
type Yuan = number & { readonly __brand: 'Yuan' };
// 单位: 米 vs 英尺
type Meters = number & { readonly __brand: 'Meters' };
// 未校验 vs 已校验的输入(让"已校验"成为一种类型, 用类型保证经过了校验)
type RawEmail = string & { readonly __brand: 'RawEmail' };
type ValidEmail = string & { readonly __brand: 'ValidEmail' };
function validateEmail(raw: RawEmail): ValidEmail | null { /* 校验, 通过才标记为ValidEmail */ }
function sendTo(email: ValidEmail) { /* 只接受已校验的email → 类型层面强制"必须先校验" */ }
# ====== 使用品牌类型的要点 ======
# 1. 适用: "多个语义不同、但底层类型相同(number/string)、混淆后果严重"的值——
# 各种ID、金额单位(分/元)、物理单位、坐标系、已校验/未校验的数据等;
# 2. 别过度: 不是所有number/string都要包品牌, 只对"易混且混了出大事"的关键值用, 否则徒增繁琐;
# 3. 运行时零开销: __brand只在类型层面, 编译后就是原始number/string, 不影响性能;
# 4. 配套构造函数: 提供 toUserId() 等"打标记"的入口(通常在数据进入系统的边界处标记一次);
# 5. 也可用class包装(真nominal)或第三方库(如ts-brand), 但交叉类型品牌最轻量。
# ====== 更普适的认知 ======
# - "类型相同" ≠ "语义相同"; 当语义重要且易混时, 主动用类型把语义"编码"进去(让类型携带更多信息);
# - 好的类型设计, 是"让非法/错误的状态无法被表示/无法通过编译"(make illegal states unrepresentable)。
# 核心: 对语义不同但底层类型相同、易混且后果重的值(各种ID/金额/单位), 用品牌类型制造结构差异,
# 让TS的结构化类型能区分它们、传错就编译报错; 把语义编码进类型, 让错误状态无法通过编译。
修复的核心,是"用品牌类型把语义编码进类型,制造结构差异让 TS 能区分"。正解:品牌类型——给 ID 定义 type UserId = number & { readonly __brand: 'UserId' },用构造函数把裸 number 标记成品牌类型,函数参数用品牌类型;传错就编译报错(品牌不同结构不同),而 __brand 只在类型层面、运行时零开销。应用:不止 ID,金额(分/元)、单位(米/英尺)、已校验/未校验的输入都可用品牌类型区分。要点:对"易混且混了出大事"的关键值用、别过度、运行时零开销、配套构造函数在边界处标记、好的类型设计是让非法状态无法通过编译。归根结底:对语义不同但底层类型相同、易混且后果重的值(各种 ID/金额/单位),用品牌类型制造结构差异,让 TS 的结构化类型能区分它们、传错就编译报错;把语义编码进类型,让错误状态无法通过编译。
第三件事:TypeScript 类型系统中其他容易误解的地方
排查后我把 TS 类型系统中其他容易误解、踩坑的地方也系统梳理了一遍。
TS 类型系统其他容易误解的地方
# 1. 结构化类型混淆语义(本文): 结构相同则兼容。→ 品牌类型制造结构差异。
# 2. any关闭检查且传染(同540篇): 一个any漏一片。→ 用unknown、边界收窄。
# 3. as类型断言绕过检查: 凭空断言, 错了运行时崩。→ 慎用, 边界用运行时校验。
# 4. 类型只在编译期, 运行时无(类型擦除, 同348篇): 别指望运行时按类型校验。→ 边界运行时校验。
# 5. 可选属性/索引签名返回的undefined: 没开noUncheckedIndexedAccess时, arr[i]类型不含undefined。→ 开启它。
# 6. 协变/逆变带来的不安全: 如数组协变, 子类数组赋给父类数组可能不安全。→ 留意可变集合。
# 7. 对象字面量的"多余属性检查"只在字面量直接赋值时触发: 经变量中转就不查了。→ 知其局限。
# 8. 枚举(尤其数字枚举)的反向映射/可被任意number赋值: → 优先用字符串枚举或 as const 联合类型。
# 共同根源: TS的类型系统是"加在JS之上的、编译期的、结构化的"一层; 它有自己的规则和"力所不能及"之处;
# 把它当成"和某些语言一样的、能保证一切的强类型系统"来想当然信赖, 就会在它的特性/边界上踩坑。
# 核心: 理解TS类型系统的特性与边界(结构化、编译期、可被as/any绕过); 主动用好它(品牌类型/严格配置/
# 让非法状态不可表示), 也清楚它管不到的地方(运行时、语义)要靠校验补上; 别盲目信赖, 也别不会用。
排查让我把 TS 类型系统其他容易误解的地方也梳理清了。一、结构化类型混淆语义(本文)。二、any 关闭检查且传染。三、as 断言绕过检查。四、类型只在编译期(类型擦除)。五、索引访问的 undefined。六、协变/逆变的不安全。七、多余属性检查的局限。八、枚举的反向映射。它们的共同根源是:TS 的类型系统是"加在 JS 之上的、编译期的、结构化的"一层;它有自己的规则和"力所不能及"之处;把它当成"能保证一切的强类型系统"来想当然信赖,就会在它的特性/边界上踩坑。核心是:理解 TS 类型系统的特性与边界(结构化、编译期、可被 as/any 绕过);主动用好它(品牌类型/严格配置/让非法状态不可表示),也清楚它管不到的地方(运行时、语义)要靠校验补上;别盲目信赖,也别不会用。下面这张图,是这次结构化类型坑的成因与解法:
第四件事:结构化类型 vs 名义类型对比表
这次踩坑后,我把结构化类型和名义类型的区别对比成一张表。
| 维度 | 结构化类型(TS) | 名义类型(Java/C#) |
|---|---|---|
| 兼容判据 | 结构(形状)是否相同 | 名字/声明是否相同 |
| 结构相同名字不同 | 兼容(可互换) | 不兼容(不同类型) |
| 区分 userId/productId | ✗ 不能(都是number) | 能(不同类 |
| 灵活性/鸭子类型 | 高(结构对就行) | 低(要显式声明) |
| 语义混淆风险 | 有(需品牌类型补) | 小 |
| TS 里的对策 | 品牌类型造结构差异 | — |
这张表把两种类型系统钉清了。核心是:结构化类型的哲学是"长得一样就是一样(鸭子类型)"——这带来了极大的灵活性(不用显式声明实现关系、组合方便),但代价是它"认结构不认名分":它看不见你赋予类型的"语义/意图",只看得见类型的"物理形状";而"形状相同"和"意义相同",是两回事。它给我的最大启发是:"判断两个东西是否'相同/可互换'",有两种维度——"它们长得一样吗(结构/形式)" 和 "它们是同一种东西吗(语义/意图)";这两个维度常常不一致:形式相同的东西可能语义迥异(userId 和 productId)、形式不同的东西可能语义相通;很多混淆和错误, 都源于"把'形式相同'误当成了'语义相同'"。这给了我一种建模时的清醒:给事物建模时(定义类型、设计数据结构),要意识到"仅靠'形式/结构', 不足以表达'语义/意图'"——当语义的区分很重要时,要主动想办法"把语义也显式地编码进去"(如品牌类型、专门的值对象、带标签的联合类型),而不是寄望于"形式相同的东西, 系统/别人能自动领会它们语义不同";"让模型不仅表达形式、也表达语义",是构建不易混淆、自我表达清晰的系统的关键。认清形式相同不等于语义相同、主动把语义编码进模型——是这个坑带给我的认知。
第五件事:这次事故暴露的"让类型多承担一些工作"的思路
这次让我反思更深一层:品牌类型这个解法,本质是"让类型系统替我多检查一些东西"。我把"类型只表达基本形状"和"类型编码更多语义"对比成表。
| 维度 | 类型只表达形状(number) | 类型编码语义(UserId) |
|---|---|---|
| 表达的信息 | 少(就是个数) | 多(是用户ID) |
| 能拦住的错 | 少(传 string 才报) | 多(传错 ID 也报) |
| 错误暴露时机 | 运行时 | 编译期 |
| 代码自文档性 | 弱(看不出含义) | 强(类型即文档) |
| 本质 | 类型干得少 | 让类型多干活 |
这张表道出了一种强大的思路。核心是:品牌类型的本质,是"把更多的'正确性约束/语义信息'编码进类型里, 让类型系统(编译器)替我自动地、在编译期检查它们"——我多花一点功夫定义类型,就能把"不能把商品 ID 当用户 ID"这条规则,从"靠人记、靠运行时撞"变成"编译器自动强制执行"。它给我的深刻启发是:类型系统不只是"防止 string 当 number 用"的初级护栏——它是一个"可以被你'编程', 让它替你在编译期自动验证各种约束"的强大工具;你往类型里编码进多少"正确性的约束/领域的语义",它就能替你自动拦住多少类对应的错误;"让非法的状态无法被表示(make illegal states unrepresentable)" —— 用类型设计, 让那些不该发生的情况, 根本无法通过编译。这给了我一种用类型的进取心:设计类型时,不要满足于"能编译过就行",而要主动想"我能不能让类型多表达一点、多拦住一点?能不能用类型把这条业务规则/这个不变量编码进去, 让编译器替我守住?"——把类型当成"主动的、可编程的正确性卫士",而非"被动的、最低限度的形状标注";"把约束和语义尽量编码进类型, 让编译器替你自动守护正确性",是写出更难出错、更自我表达的代码的高级用法。认清类型可被编程来编码约束、让非法状态无法通过编译——是这个品牌类型坑带给我的认知升华。
第六件事:定义类型时,我现在的自检习惯
现在每当我要给一个值定义类型,我都会先按这张图问自己:
这张图的精髓,是"语义不同但结构相同、混了出大事的值,用品牌类型让 TS 区分"。语义独特用基础类型、各种 ID/金额混了出大事用品牌类型、轻微的别过度,品牌类型配套构造函数边界打标记。这套习惯,让我从"ID 金额都用 number 了事"变成了"语义重要易混的值用品牌类型编码进去"——核心始终是:TS 是结构化类型按结构判兼容,对语义不同但底层类型相同、易混且后果重的值用品牌类型制造结构差异,让传错就编译报错。
我立下的几条规矩
这场"把商品 ID 当用户 ID、TS 却不报错"的事故,换来了我写 TypeScript 时,刻进骨子里的几条铁律:
- TS 是结构化类型系统,按"结构是否相同"判断兼容,不看名字。这是它的核心机制。
- "类型相同"不等于"语义相同"。都是 number 的 userId/productId 会被认为可互换。
- TS 拦得住结构不同(string vs number),拦不住结构相同语义不同的错。
- 语义不同、易混、后果重的值(ID/金额/单位)用品牌类型。制造结构差异让 TS 区分。
- 品牌类型用 number & { __brand }, 运行时零开销。__brand 只在类型层面。
- 在数据进入系统的边界处用构造函数打标记。之后享受类型保护。
- 把约束和语义尽量编码进类型,让非法状态无法通过编译。让编译器替你守护。
写在最后
回头看,这场由"两个都是 number 的 ID 被混用"引发的、数据张冠李戴的事故,真正教给我的,远不止"用品牌类型区分 ID"这一个技巧。它让我对"'形式上的相同' 不等于 '本质上的相同'; 我们常常因为两个东西'看起来一样'就把它们当成一回事, 而忽略了它们'意义上的天壤之别'",有了一次刻骨的体会。我栽跟头,是因为我和 TypeScript 都犯了同一个错误——只看到了 userId 和 productId "形式上的相同"(都是 number),却忽略了它们"意义上的截然不同"(一个指向用户、一个指向商品)。TS 因为它"结构化"的本性,天生只看形式;而我,则是想当然地以为"类型对了 = 用对了",把"形式的正确(都是 number)"误当成了"语义的正确(用对了 ID)";于是一个商品 ID,就这样大摇大摆地、以"反正都是 number"的名义,混进了本该只属于用户 ID 的地方。这让我领悟到一个关于"形似与神似"的深刻认知:判断两个事物是否"真正相同、可以互换",绝不能只停留在"它们的表现形式/结构是否一样",更要深究"它们的本质、意义、所处的语境是否相同"——"形似"是浅层的、容易判断的;"神似(语义相同)"才是深层的、真正决定能否互换的;世间太多的错误,源于"被表面的相似性迷惑, 把'形似'当成了'神同'"——把结构相同的类型当同类、把字面相同的概念当一回事、把表象类似的问题套用同一个解法。这给了我一种更求本质的审慎:面对"看起来相同/相似"的事物时,要多追问一层"它们'是什么'(语义/本质)真的一样吗?还是只是'长什么样'(形式)碰巧一样?"——尤其在它们"意义不同却会被当成相同来对待"会酿成大错时,要主动用某种方式(如品牌类型)把这层"本质的不同"显式地标记、固化下来;"看穿形式相同之下的本质差异、不被表面的相似迷惑",是避免'张冠李戴'式错误的根本认知。认清形似不等于神同、看穿形式相同下的本质差异并把它显式固化——这,是我用一次 ID 混用的事故,换来的、关于 TypeScript 类型系统、也关于如何辨别事物真正异同的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次定义那些"都是 number/string、但语义不同、混了要命"的值时,想起用品牌类型把它们的"不同"钉死,那我对着那条张冠李戴的数据排查的这段时间,就值了。
—— 别看了 · 2026