我按索引取数组元素、直接用它的属性,TypeScript 一声没吭,可那个索引越界了、取到的是 undefined,运行时直接炸:一次 TS 索引访问类型漏洞的深度复盘
那个运行时崩溃是某次索引算超了才暴露的:我有个数组 const items: Item[],某处按索引取一个元素、直接用它的属性:const item = items[index]; console.log(item.name);。TypeScript 编译一声没吭,我也以为稳了。可线上某次,那个 index 因为某个计算越界了(比如算成了 10,而数组只有 5 个元素),程序当场炸了:TypeError: Cannot read properties of undefined (reading 'name')。我盯着这行"TS 明明检查通过了"的代码愣住了:既然 items 是 Item[],items[index] 不就是 Item 吗?怎么会是 undefined?TS 怎么没拦住?我查清 TS 的索引访问类型规则,才看明白,后背发凉:问题出在 TypeScript 默认对"索引访问"的类型处理是"不安全"的。对于 items[index](数组按下标访问)或 obj[key](对象按 key 访问),TS 默认认为它的类型就是元素类型 Item——它不检查"这个索引会不会越界、这个 key 会不会不存在";可运行时,越界的 items[10]、不存在的 obj["missing"],返回的都是 undefined;于是 TS 给了我"item 是 Item、放心用"的虚假承诺,而运行时 item 其实是 undefined,我一访问 item.name 就炸了。根本原因是:TS 默认的索引访问类型,只看"元素类型",不考虑"索引可能越界/key 可能不存在→返回 undefined"这个运行时现实;它给了类型安全的假象,却没把 undefined 这个可能性纳入类型。问题的根,是 TS 默认索引访问 items[i]/obj[key] 的类型不含 undefined(认为就是元素类型),但运行时越界/不存在会返回 undefined,类型和现实不符导致运行时崩。这篇就把这次"索引访问类型漏洞"的坑,从头到尾复盘一遍。
故障现场:索引越界,item 是 undefined,TS 却不报错
问题在于 TS 默认认为索引访问返回元素类型、不含 undefined:
// ✗ 出问题的代码: 按索引取元素直接用, TS不报错
const items: Item[] = [a, b, c]; // 只有3个元素
const index = computeIndex(); // 某次算成了 10(越界)
const item = items[index]; // TS认为 item 的类型是 Item(不含undefined)!
console.log(item.name); // ✗ 运行时: items[10] 是 undefined → undefined.name → TypeError!
// 为什么TS不报错? 因为TS默认的索引访问类型是"不安全"的:
// 1. items 是 Item[], TS默认 items[任意number] 的类型就是 Item;
// 2. 它【不检查】index会不会越界——TS不知道运行时index是多少, 默认乐观地认为"能取到Item";
// 3. 但运行时: 越界的 items[10] 返回 undefined(JS的行为);
// 4. → TS说item是Item(不含undefined), 运行时item却是undefined → item.name 炸了。
// 同样的坑在对象/Record上:
const map: Record = { alice: userA };
const u = map["bob"]; // TS认为 u 是 User(不含undefined)!
u.name; // ✗ 运行时: map["bob"]不存在=undefined → 炸
// 也包括: Map.get其实TS标了可能undefined(它的类型是 V | undefined, 较安全),
// 但 数组下标[] 和 Record/索引签名[] 默认【不带undefined】 → 这是不安全的根源。
// 根源: TS默认没开启 noUncheckedIndexedAccess, 索引访问的类型【不包含undefined】,
// 于是"越界/key不存在→undefined"这个运行时可能性, 没被类型系统反映出来 → 类型骗了你。
// 关键: TS默认索引访问 arr[i]/obj[key] 的类型是元素类型(不含undefined), 不检查越界/key不存在;
// 但运行时这些情况返回undefined → 类型安全是假象, 直接用其属性会运行时崩。
第一次想明白"原来 items[index] 在 TS 眼里永远是 Item、它根本不管会不会越界"时,我又懊恼又意外:"我一直以为 TS 既然知道 items 是 Item[],就会帮我保证取出来的是 Item;完全没想到它对越界这事儿压根不检查,给了我一个'它是 Item'的空头支票。"这个坑最隐蔽的地方在于:它恰恰发生在你最信赖类型检查的地方——TS 告诉你"item 是 Item、安全",你便放心地用 item.name,而这个"安全"是假的;它不报任何编译错误,只在运行时索引真的越界时才崩;而越界又常是偶发的(某个边界计算/数据变化),平时不触发。下面就来拆解,这个漏洞和怎么补。
第一件事:搞懂 TS 索引访问的"不安全默认"
我顺着这次事故,把 TS 索引访问的类型行为彻底理清了。
TS 索引访问 arr[i]/obj[key] 的类型为什么不安全?
【核心: TS默认索引访问的类型=元素类型(不含undefined), 不检查越界/key不存在; 运行时这些返undefined, 类型骗了你; 开 noUncheckedIndexedAccess 让它带undefined】
1. TS默认的索引访问类型:
- 数组 arr: T[], arr[number] 的类型默认是 T(不是 T | undefined);
- 对象 Record / 索引签名 {[k: string]: V}, obj[key] 默认是 V(不是 V | undefined);
- → TS默认乐观地认为"索引/key一定能取到值", 不把"取不到→undefined"算进类型。
2. 但运行时的现实:
- 数组越界 arr[10](只有3个元素): 返回 undefined;
- 对象不存在的key obj["missing"]: 返回 undefined;
- → 类型说是T, 运行时却可能是undefined → 类型和现实不符。
3. 为什么TS默认这样(不安全)?
- 历史/便利权衡: 如果每次arr[i]都是 T|undefined, 那遍历、用下标会处处要判undefined, 很烦;
- 所以TS默认"假设你知道自己在合法范围内访问", 把安全性让位给便利;
- → 代价就是: 越界/key不存在时, 类型骗了你, 运行时崩。
4. 解药: noUncheckedIndexedAccess 编译选项
- 开启后, arr[i] 的类型变成 T | undefined, obj[key] 变成 V | undefined;
- → TS就会【强制你处理undefined的可能】(用前判断/可选链), 把这个漏洞堵上;
- 代价: 代码里要多写一些undefined处理(但这正是"真实存在的可能性", 该处理)。
5. 其他相关:
- Map.get(key) 的类型是 V | undefined(TS标对了, 较安全) → 强制你判;
- find()/pop()等也返回 T | undefined(标对了);
- 唯独"下标访问[]"默认不带undefined(不安全) → 这是最容易踩的点。
6. 访问后用前要判: 无论开不开选项, 对"可能取不到"的索引访问, 用前都该判断/可选链/默认值。
一句话: TS默认索引访问 arr[i]/obj[key] 的类型是元素类型(不含undefined)、不检查越界/key不存在, 给了
虚假的类型安全; 运行时越界/不存在返undefined会崩; 开 noUncheckedIndexedAccess 让其带undefined、强制处理。
这套认知,是整个坑的根。TS 默认的索引访问类型:数组 arr[number] 默认是 T(不是 T|undefined)、对象 obj[key] 默认是 V;TS 默认乐观地认为索引一定能取到值,不把"取不到→undefined"算进类型。但运行时的现实:数组越界、对象不存在的 key 都返回 undefined,类型说是 T 运行时却可能是 undefined。为什么默认这样:历史/便利权衡(否则下标访问处处要判 undefined 很烦),代价是越界时类型骗你、运行时崩。解药:noUncheckedIndexedAccess 选项——开启后 arr[i] 变成 T|undefined,强制你处理 undefined 的可能,把漏洞堵上。其他相关:Map.get/find/pop 的类型标对了(带 undefined),唯独下标访问 [] 默认不带 undefined(最易踩)。一句话:TS 默认索引访问 arr[i]/obj[key] 的类型是元素类型(不含 undefined)、不检查越界/key 不存在,给了虚假的类型安全;运行时越界/不存在返 undefined 会崩;开 noUncheckedIndexedAccess 让其带 undefined、强制处理。
第二件事:正解——开 noUncheckedIndexedAccess、访问后判 undefined
搞懂了原理,正解就清晰了:开启 noUncheckedIndexedAccess 让索引访问的类型带 undefined、强制处理;访问可能取不到的索引后,用可选链/判断/默认值兜住 undefined。
// ====== 正解一: 开启 noUncheckedIndexedAccess(tsconfig) ======
// tsconfig.json:
// { "compilerOptions": { "noUncheckedIndexedAccess": true } }
const items: Item[] = [a, b, c];
const item = items[index]; // ★ 开启后: item 的类型是 Item | undefined
// console.log(item.name); // ✗ 现在编译报错! item可能是undefined, 逼你处理
if (item) {
console.log(item.name); // ✓ 判过非空, 这里item是Item, 安全
}
// ====== 正解二: 访问后用可选链/默认值兜住 undefined ======
console.log(items[index]?.name); // 可选链: 是undefined就短路, 不炸
const it = items[index] ?? defaultItem; // 空值合并: 给默认值
// 对象/Record:
const u = map[key]; // 开启选项后: User | undefined
if (u) { /* 用u */ }
// 或 map[key]?.name / map[key] ?? defaultUser
# ====== 处理索引访问的要点 ======
# 1. 开启 noUncheckedIndexedAccess: 让 arr[i]/obj[key] 的类型带 undefined, 从源头强制你处理"取不到";
# (新项目强烈建议开; 老项目开了会冒出很多要处理undefined的地方, 但那都是真实的潜在bug);
# 2. 访问"可能取不到"的索引后, 用前要判: if(x)/可选链 x?.prop/空值合并 x ?? default;
# 3. 已知一定在范围内(如刚push、刚检查过length): 可以用, 但仍建议显式表达(或断言并注释为什么安全);
# 4. 遍历优先用 for-of / map / forEach(直接拿元素, 不走下标), 减少下标越界的机会;
# 5. Map.get/find/pop 返回的本就是 T|undefined, 老老实实处理那个undefined;
# 6. 别用非空断言 arr[i]! 来"消除"undefined(那只是骗编译器, 运行时照样崩)——要真的处理它。
# ====== 一个原则 ======
# - 类型系统的"默认行为"未必都安全(为便利做了妥协); 知道哪些默认是不安全的, 主动用更严格的配置补上;
# - "运行时真实存在的可能性(如取不到=undefined)", 就该被类型如实反映、并被强制处理。
# 核心: 开 noUncheckedIndexedAccess 让索引访问类型带undefined、强制处理; 访问可能取不到的索引后用可选链/
# 判断/默认值兜住undefined; 别用!断言糊弄; 遍历少用下标; 让类型如实反映运行时的undefined可能。
修复的核心,是"开 noUncheckedIndexedAccess、访问后判 undefined"。正解一:开启 noUncheckedIndexedAccess——让 items[index] 的类型变成 Item | undefined,直接用 item.name 就编译报错、逼你处理(判过非空再用)。正解二:访问后用可选链/默认值——items[index]?.name、items[index] ?? defaultItem。要点:开 noUncheckedIndexedAccess、访问后用前判、已知在范围内可用但显式表达、遍历用 for-of 减少下标、Map.get 老实处理 undefined、别用 ! 断言糊弄(运行时照样崩)。原则:类型系统的默认行为未必都安全(为便利妥协),知道哪些默认不安全就主动用更严格配置补上;运行时真实存在的可能性该被类型如实反映并强制处理。归根结底:开 noUncheckedIndexedAccess 让索引访问类型带 undefined、强制处理;访问可能取不到的索引后用可选链/判断/默认值兜住 undefined;别用 ! 断言糊弄;遍历少用下标;让类型如实反映运行时的 undefined 可能。
第三件事:TS 类型系统"默认不够严"的其他地方
排查后我把 TS 中其他"默认不够严格、需要主动开严"的地方也系统梳理了一遍。
TS 默认不够严、需主动开严的其他地方
# 1. 索引访问不含undefined(本文): arr[i]默认是T。→ noUncheckedIndexedAccess。
# 2. 不开strict: 默认很多检查关着(隐式any、null检查等)。→ "strict": true 全家桶。
# 3. strictNullChecks关: null/undefined能赋给任何类型, NPE温床。→ 开strictNullChecks(strict含)。
# 4. noImplicitAny关(同540篇): 推断不出类型悄悄变any。→ 开noImplicitAny。
# 5. 函数参数/返回的可空没标: 返回可能null却没标T|null。→ 如实标注可空。
# 6. exactOptionalPropertyTypes关: 可选属性和undefined混淆。→ 视需要开。
# 7. as断言/!非空断言滥用: 强行绕过检查。→ 少用, 边界用运行时校验。
# 8. 第三方库类型不严: 库的.d.ts可能标得宽松/不准。→ 包一层/校验。
# 共同根源: TS为了"渐进采用、和JS兼容、不太啰嗦", 很多检查默认是"宽松/关闭"的;
# 这些宽松的默认, 让代码"更容易编译过", 但也"放过了一些真实的潜在bug"(undefined、any、null);
# 想要TS真正发挥类型安全的价值, 需要【主动把严格选项开起来】, 别满足于默认。
# 核心: TS的默认是偏宽松的(为兼容和便利); 想要真正的类型安全, 主动开启strict全家桶 +
# noUncheckedIndexedAccess等更严的选项; 别用!/as糊弄; 让类型系统严格地、如实地反映运行时的可能性。
排查让我把 TS 默认不够严的其他地方也梳理清了。一、索引访问不含 undefined(本文)。二、不开 strict。三、strictNullChecks 关。四、noImplicitAny 关。五、函数可空没标。六、exactOptionalPropertyTypes 关。七、as/! 断言滥用。八、第三方库类型不严。它们的共同根源是:TS 为了"渐进采用、和 JS 兼容、不太啰嗦",很多检查默认是宽松/关闭的;这些宽松的默认让代码更容易编译过,但也放过了一些真实的潜在 bug(undefined、any、null);想要 TS 真正发挥类型安全的价值,需要主动把严格选项开起来,别满足于默认。核心是:TS 的默认是偏宽松的(为兼容和便利);想要真正的类型安全,主动开启 strict 全家桶+noUncheckedIndexedAccess 等更严的选项;别用 !/as 糊弄;让类型系统严格地、如实地反映运行时的可能性。下面这张图,是这次索引访问坑的成因与解法:
第四件事:默认不安全 vs 开启 noUncheckedIndexedAccess 对比表
这次踩坑后,我把"默认索引访问"和"开启严格选项后"对比成一张表。
| 维度 | 默认(不安全) | 开 noUncheckedIndexedAccess |
|---|---|---|
| arr[i] 的类型 | T(不含 undefined) | T | undefined |
| 越界/key 不存在 | 类型说是 T(骗你) | 类型反映了 undefined |
| 直接用属性 | 编译通过, 运行时崩 | 编译报错, 逼你处理 |
| 编码体验 | 省事(但不安全) | 要多写 undefined 处理 |
| 暴露问题时机 | 运行时(晚、被动) | 编译期(早、主动) |
这张表把两者钉清了。核心是:开不开这个选项的差别,本质是"把'取不到=undefined'这个真实可能性, 是藏起来(默认), 还是摆到台面上(开启)"——默认是"假装它不会发生"(类型不含 undefined), 开启是"承认它可能发生、并逼你处理"(类型含 undefined);而那个可能性客观存在(越界确实返回 undefined), 藏起来不等于消除, 只是把暴露推迟到了运行时。它给我的最大启发是:对待"一个真实存在的、可能出错的情况",有两种态度——"假装它不存在(乐观默认)" 和 "承认它存在并强制处理(严格)";"假装不存在"换来一时的省事(代码少、编译易过),代价是问题在运行时以更糟的方式爆发;"承认并强制处理"前期麻烦(要多写处理),但把问题挡在了编译期;这又是"把问题暴露在早处(fail-fast/编译期)"的价值(同 545 篇)。这给了我一种选择"严格度"的清醒:在"宽松省事(放过潜在问题)"和"严格(强制处理潜在问题)"之间,对"正确性重要的代码",要主动选择更严格的一端——宁可前期多写些处理,也要让工具(类型/检查)把真实存在的潜在问题在编译期就逼出来;"主动调高工具的严格度、让它替你在早期揪出潜在问题",是用类型/静态检查保障质量的关键态度——别贪图默认的省事。认清默认乐观是把真实可能性藏起来推迟到运行时、主动选严格让问题在编译期暴露——是这个坑带给我的认知。
第五件事:这次事故暴露的"类型说的"和"运行时做的"不一致
这次让我反思更深一层:TS 类型说"item 是 Item",运行时它却是 undefined——类型和现实脱节了。我把"类型的承诺"和"运行时的现实"对比成表。
| 维度 | 类型说的(默认下) | 运行时做的 |
|---|---|---|
| items[index] | 是 Item | 越界时是 undefined |
| 能否放心用属性 | (类型说)能 | (实际)越界就崩 |
| 一致吗 | 不一致(类型骗了你) | |
| 原因 | 默认类型没反映"取不到"的可能 | |
| 后果 | 信了类型的承诺, 运行时被现实打脸 | |
这张表道出了问题的本质。核心是:类型系统的价值,在于它对运行时行为的"承诺"是可信的——它说"这是 Item",你就能放心当 Item 用;可一旦类型的承诺和运行时的现实脱节(类型说是 Item、运行时是 undefined),类型系统就从"可信的保障"变成了"误导你的假象"——你越信任它, 被它误导得越惨。它给我的深刻启发是:任何"保障/规范/契约"的价值,都建立在它"真实可信、和现实一致"的前提上——"说的"必须等于"做的";一个"说一套、做一套"(承诺和现实不符)的保障,比"没有保障"更危险,因为它骗取了你的信任、让你放下了戒备(同 565 篇"虚假安全感");"名实不符的保障", 是一种隐蔽的陷阱。这给了我一种对待"保障/契约"的清醒:依赖任何"保障"(类型、接口契约、文档承诺、测试)时,要确认"它的承诺和运行时的真实行为是否一致"——对那些"已知会脱节"的地方(如默认的索引访问), 要主动校准(开严格选项/运行时校验/不盲信),让"保障所说的"真正等于"系统所做的";"确保保障与现实一致、不依赖名实不符的承诺",是让类型系统等保障机制真正可信、可依赖的根本。认清类型承诺与运行时脱节比没保障更危险、要确保保障与现实一致——是这个索引访问坑带给我的认知。
第六件事:按索引/key 取值时,我现在的自检习惯
现在每当我要按索引或 key 取值,我都会先按这张图问自己:
这张图的精髓,是"开 noUncheckedIndexedAccess、访问可能取不到的就判 undefined、遍历少走下标"。没开建议开、不一定存在判断/可选链/默认值、一定存在显式表达、遍历用 for-of。这套习惯,让我从"按索引取了就直接用"变成了"开严格选项、访问后处理 undefined"——核心始终是:开 noUncheckedIndexedAccess 让索引访问类型带 undefined、强制处理;访问可能取不到的索引后用可选链/判断/默认值兜住 undefined,让类型如实反映运行时可能。
我立下的几条规矩
这场"索引越界取到 undefined、TS 却不报错"的事故,换来了我写 TypeScript 时,刻进骨子里的几条铁律:
- TS 默认索引访问 arr[i]/obj[key] 的类型是元素类型,不含 undefined。
- TS 不检查越界/key 不存在,但运行时这些情况返回 undefined,类型骗了你。
- 开启 noUncheckedIndexedAccess,让索引访问的类型带 undefined、强制你处理。
- 访问可能取不到的索引后,用判断/可选链(?.)/默认值(??)兜住 undefined。
- 别用非空断言(!)来"消除" undefined,那只是骗编译器,运行时照样崩。
- 遍历优先用 for-of/map,少走下标,减少越界机会。
- TS 默认偏宽松,想要真正安全要主动开 strict 全家桶等严格选项。
写在最后
回头看,这场由"索引访问类型不含 undefined"引发的、运行时崩溃的事故,真正教给我的,远不止"开 noUncheckedIndexedAccess"这一个技巧。它让我对"一个'给我们提供安全感的工具', 它的'安全保证'其实是有'边界和折扣'的; 而我们若把它'打折的保证'当成了'十足的保证'去全然信赖, 就会在它打折的地方、毫无防备地栽倒",有了一次刻骨的体会。我栽跟头,是因为我对 TypeScript 的类型检查,有一种"它既然检查了, 就一定保证安全"的过度信赖——它说"item 是 Item",我就百分百地信了,放心地 item.name,连"万一它是 undefined"的念头都没有;可我不知道:TS 的这个"保证",在"索引访问"这件事上,是打了折的、不完整的(默认没把越界的 undefined 算进去);我把它"打折的、有边界的保证",当成了"无条件的、十足的保证"——于是在它保证不到的那个角落(索引越界), 我因为太信任而毫无防备, 被运行时的 undefined 一击即中。这让我领悟到一个关于"信任工具的边界"的深刻认知:我们依赖的每一个"提供保障的工具"(类型检查、测试、断言、各种自动校验),它们的保障都是有"覆盖边界"的——它保证了什么、没保证什么、在什么地方打了折;"知道一个工具'不保证什么'", 和知道它'保证什么', 同样重要——甚至更重要;因为危险恰恰藏在"你以为它保证了、其实它没保证"的认知盲区里。这给了我一种使用保障工具的成熟态度:使用任何"提供保障"的工具时,不仅要知道"它能帮我挡住什么",更要清醒地了解"它的保障边界在哪、它'不'帮我挡什么"——在它保障不到的地方(如默认索引访问), 自己补上防护(开严格选项/运行时校验/手动判断), 而不是因为"有这个工具"就全然放下戒备;"认清保障工具的能力边界、在它够不到的地方自己补位",是真正用好工具、而不被'它没覆盖的盲区'反噬的成熟。认清保障工具的保证有边界和折扣、要知道它不保证什么并在盲区自己补位——这,是我用一次索引访问的事故,换来的、关于 TypeScript、也关于如何清醒地信赖保障工具的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次按索引取值时,开上 noUncheckedIndexedAccess、把那个可能的 undefined 处理掉,那我对着那行运行时崩溃的 item.name 排查的这段时间,就值了。
—— 别看了 · 2026