9.2 常见陷阱与反模式


9.2 常见陷阱与反模式

这节在地图里是全册的"排错收口"。前面九章教你怎么写对,这里把你大概率会踩的坑集中列出来,每条给"现象—成因—改写"。把这篇当 Checklist,能在 code review 时一眼揪出退化成 any 的代码。

陷阱一:any 泄漏污染整条链路

// 反模式:一个 any 让下游全失去检查 function load() { return JSON.parse("{}"); } // 返回 any const data = load(); data.user.address.zip; // 编译通过,运行时炸 // 改写:用 unknown + 收窄,或 zod 运行时校验 function loadSafe(): unknown { return JSON.parse("{}"); } const raw = loadSafe(); if (typeof raw === "object" && raw !== null && "user" in raw) { // 收窄后再用 }

背景:从 JSON.parse/第三方库拿到 any,未经处理直接传开。操作:源头标 unknown。结果:下游被迫先证明再使用。解读:any 像病毒,一处放弃检查,整条调用链都失去契约。

陷阱二:非空断言 ! 掩盖真实空值

// 反模式 const el = document.getElementById("app")!; el.addEventListener(...); // 若 id 拼错,el 是 null,运行时崩 // 改写:收窄 const el = document.getElementById("app"); if (el) el.addEventListener(...);

! 把"可能为 null"强行当"一定非 null",把编译期保护让给运行时赌运气。能收窄就别断言。

陷阱三:用 enum 当字典又嫌包大

第三章讲过,数字 enum 编译后留对象。在包体敏感的前端里,改用 const enum 或字面量联合。

// 反模式(前端包体) enum Color { Red, Green, Blue } // 改写 const Color = { Red: "red", Green: "green", Blue: "blue" } as const; type Color = typeof Color[keyof typeof Color];

陷阱四:过度嵌套的条件类型

第五章方法论强调过。三层以上的条件类型报错天书,且没人维护。

// 反模式:炫技但不可维护 type DeepMerge<A, B> = ... // 几十行 // 改写:拆成带注释的中间类型,或用库(如 ts-essentials)

这张图把"反模式→正确做法"对照列出:

09-06-fig01

陷阱五:忘了类型擦除,把类型当运行时校验

// 反模式 interface Admin { level: number; } function isAdmin(u: unknown) { return (u as Admin) !== undefined; // 类型断言不参与运行时!永远 true } // 改写 function isAdmin(u: unknown): u is Admin { return typeof u === "object" && u !== null && "level" in u; }

as 是编译期提示,运行时不产生任何代码。想运行时判断,必须用值层面的检查或 in/instanceof

陷阱六:在业务代码滥用 as 绕过报错

// 反模式:用 as 把错误摁下去 const x = someValue as string; // 其实可能是 number,埋雷 // 改写:先查类型再决定,或修正源头类型标注

as 频繁出现,通常是源头类型设计有问题,而不是这里需要断言。治本比摁住好。

陷阱七:Record<string, T> 配合索引访问退化成 any

Record 很方便,但索引访问在 noUncheckedIndexedAccess 关闭时返回的是 T 而不是 T | undefined,一旦键拼错就拿到错误值却无任何提示。

// 反模式:关掉 noUncheckedIndexedAccess 时 type Price = Record<string, number>; const table: Price = { apple: 3, banana: 5 }; const p = table["banan"]; // 拼写错误,返回 undefined,但类型仍是 number p.toFixed(2); // 编译通过,运行时 TypeError // 改写:开启 noUncheckedIndexedAccess,并做收窄 type PriceSafe = Record<string, number>; const table2: PriceSafe = { apple: 3, banana: 5 }; const q = table2["banan"]; // 类型是 number | undefined if (q !== undefined) q.toFixed(2); // 或者改用 Map 拿到更明确的语义 const map = new Map<string, number>([["apple", 3]]); const r = map.get("banan"); // number | undefined,显式处理

编译器报错示例(开启 strict + noUncheckedIndexedAccess 后):

错误 TS2532: 对象可能为“未定义”。 const p = table["banan"]; p.toFixed(2);

把"可能缺失"这件事交还给类型系统,比靠命名约定更可靠。

陷阱八:函数重载顺序写反导致匹配失效

重载列表按声明顺序自上而下匹配第一个兼容签名。把更宽松的签名放在前面,会吞掉更精确的重载。

// 反模式:宽泛签名在前 function format(v: unknown): string; function format(v: number): string; // 永不命中,因为 unknown 已吞掉一切 function format(v: any): string { return String(v); } // 改写:从最具体到最宽泛 function format(v: number): string; function format(v: string): string; function format(v: unknown): string; function format(v: any): string { return String(v); } const out = format(42); // 命中 number 重载,推断准确

背景:重载常用于兼容多形态入参的库函数。操作:排序遵循"先窄后宽"。结果:调用点推断更贴合真实分支。解读:顺序即优先级,写反等于白写窄重载。

陷阱九:readonly 只防首层,嵌套仍可变

readonly T[] 只禁止重新赋值数组引用与下标赋值,元素内部属性依旧可改,容易让人误以为"不可变"已成立。

// 反模式:以为 readonly 带来深不可变 interface User { name: string; tags: string[]; } const list: readonly User[] = [{ name: "a", tags: ["x"] }]; // list[0] = {...}; // 报错:只读 list[0].name = "b"; // 通过!嵌套属性仍可改 list[0].tags.push("y"); // 通过!内部数组仍可变 // 改写:用深只读工具或结构化冻结 type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]; }; const frozen: DeepReadonly<User> = { name: "a", tags: ["x"] }; // frozen.name = "b"; // 报错 // frozen.tags.push("y"); // 报错

想真正不可变,要么上深只读类型,要么在源头用 Object.freeze 配合 as const,别被单层 readonly 的安心感骗了。

陷阱十:infer 推断位置错配得到意外类型

条件类型里 infer 放在错误位置会得到比预期更宽或更窄的类型,尤其在提取数组元素、函数返回值时。

// 反模式:推断位置错,拿到的是数组而非元素 type Bad<T> = T extends (infer U)[] ? U[] : never; type X = Bad<string[]>; // string[],没拆出来 // 改写:infer 直接代表元素 type ElementOf<T> = T extends (infer U)[] ? U : T extends readonly (infer V)[] ? V : never; type Y = ElementOf<string[]>; // string type Z = ElementOf<readonly number[]>; // number // 提取函数返回值的正确写法 type ReturnOf<F> = F extends (...args: any[]) => infer R ? R : never; type R1 = ReturnOf<() => { id: number }>; // { id: number }

infer 是"占位抽取",占位点必须正好落在你想提取的那一层。放错层,抽到的就是包裹层本身。

工程取舍:Code Review 清单化

我们把以上反模式做成 review 清单:看到 any/as/!/enum 密集出现就多问一句"为什么"。不是全禁(应急时 as 合理),而是"无正当理由不用"。契约的价值在于持续,review 是最后一道人工护栏。

本节要点回顾

  • any 会沿调用链传染,源头改 unknown
  • ! 是赌运气,优先显式收窄
  • 前端包体敏感处慎用数字 enum
  • 类型擦除后,as 不参与运行时,别当校验用
  • as 密集出现通常是源头类型设计问题

⚠️ 别把 as 当"让报错消失"的按钮。它只是告诉编译器"别管了",运行时什么都不会变;频繁用 as 往往说明上游类型标注本来就该改。

💡 把"any / as / ! / enum"列为 review 敏感词,出现就追问理由;应急可用,但无正当理由不通过,契约才不会被悄悄啃掉。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U