TypeScript 里我用下标取数组元素、类型明明是 number,运行时却拿到 undefined 还崩了,我对着数组索引访问的类型不安全排查了大半天的复盘
那是我用 TypeScript 写的一段数据处理代码。我从一个数组里按下标取元素、再调用它的方法,类型检查一路绿灯、IDE 也没有任何警告,我信心满满。可运行时却炸了:Cannot read properties of undefined。我盯着代码满脸问号:这个变量的类型明明被 TypeScript 标成了 number(或某个对象类型),它怎么会是 undefined?TypeScript 不是号称类型安全吗,怎么连"这里可能是 undefined"都没提示我?排查了大半天,我才真正理解了 TypeScript 一个默认状态下的"类型谎言":数组和对象的索引访问,实际上是类型不安全的——以及那个能拯救我的编译选项 noUncheckedIndexedAccess。这篇就把这场"number 类型却是 undefined"的事故,从头复盘一遍。
故障现场:类型是 number,值却是 undefined
先看现场。问题就藏在那个"类型说没事、运行时却出事"的下标访问里:
const scores: number[] = [90, 85, 78];
// 我按下标取值, TypeScript 说它是 number
const score = scores[10]; // 下标越界! 实际运行时 score 是 undefined
// ^^^^^ TypeScript 推断 score 的类型是 number(不是 number | undefined!)
console.log(score.toFixed(2)); // ← 运行时崩: Cannot read properties of undefined
// 同样的坑在对象/Map/Record上:
const userMap: Record = { "alice": alice };
const user = userMap["bob"]; // "bob" 不存在! 运行时是 undefined
// ^^^^ 但 TypeScript 推断 user 的类型是 User(不是 User | undefined!)
user.name; // ← 运行时崩: Cannot read properties of undefined
// 为什么 TypeScript "骗"了我?
// - TypeScript 默认认为: 用 number 下标访问 T[] 数组, 结果就是 T。
// 即 scores[i] 的类型恒为 number, 它【假装】"下标一定在范围内"。
// - 同理: Record 用任意 key 访问, 结果类型恒为 T,
// 它【假装】"这个 key 一定存在"。
// - 但运行时, JavaScript 的真相是: 越界/不存在的访问, 返回 undefined!
// (JS 不会越界报错, 而是悄悄给个 undefined)
// - 于是: 编译期类型说"它是 number/User", 运行时却是 undefined → 类型谎言!
// 现象拼图:
// - 这是 TypeScript 在【类型安全】和【使用便利】之间的一个【默认妥协】:
// 如果每次 arr[i] 都是 T | undefined, 你就得到处判空, 很烦。
// 所以 TS 默认"假设你访问的下标都有效", 让 arr[i] 直接是 T(便利但不安全)。
// - 代价: 它对"下标越界/key不存在"这种【极常见】的情况, 视而不见,
// 不会警告你"这里可能是 undefined" → 留下运行时崩溃的隐患。
// - ★ 根因: TS 默认的下标访问类型, 是"乐观的假设", 而非"真实的反映"。
看清真相后,我又惊又无奈。问题的根源,是 TypeScript 一个默认的"类型谎言":它默认认为用 number 下标访问 T[] 数组,结果就是 T(而不是 T | undefined),即它"假装"下标一定在范围内;Record 用任意 key 访问也假装 key 一定存在。但运行时,JavaScript 的真相是:越界/不存在的访问会返回 undefined(JS 不报错,而是悄悄给个 undefined)。于是编译期类型说"它是 number/User",运行时却是 undefined——类型谎言。为什么 TS 要这么设计?这是它在"类型安全"和"使用便利"之间的默认妥协:如果每次 arr[i] 都是 T | undefined,你就得到处判空,很烦;所以 TS 默认"假设你访问的下标都有效",让 arr[i] 直接是 T(便利但不安全)。代价是:它对"下标越界/key 不存在"这种极常见的情况视而不见,不会警告"这里可能是 undefined",留下运行时崩溃的隐患;根因是 TS 默认的下标访问类型,是"乐观的假设",而非"真实的反映"。
第一件事:搞懂 noUncheckedIndexedAccess 这个救星
要解决它,得先认识那个能让 TypeScript "说真话"的编译选项:noUncheckedIndexedAccess。
noUncheckedIndexedAccess: 让索引访问"说真话"
# 默认行为(不安全):
# const x = arr[i]; // x: T (假装一定有)
# const v = record[key]; // v: T (假装key一定在)
# 开启 noUncheckedIndexedAccess 后(tsconfig.json):
# "compilerOptions": { "noUncheckedIndexedAccess": true }
# const x = arr[i]; // x: T | undefined ← 诚实地标出"可能没有"!
# const v = record[key]; // v: T | undefined ← 同理
# → 于是你【必须】先判空/收窄, 才能用 x, 否则编译报错。
# 这就把"运行时才崩"的隐患, 提前到了"编译期就报错", 强制你处理。
# 开启后, 代码会变成:
# const score = scores[10]; // score: number | undefined
# // console.log(score.toFixed(2)); // ✗ 编译报错: score 可能 undefined!
# if (score !== undefined) { // ✓ 必须先判空
# console.log(score.toFixed(2)); // 此处 score 已收窄为 number, 安全
# }
# 它的本质: 让类型系统"诚实地反映运行时的真相"
# - 运行时下标访问【确实】可能返回 undefined。
# - 默认TS隐瞒了这点(为了便利); noUncheckedIndexedAccess 让它说真话。
# - 代价: 你要写更多判空(但这些判空, 本就是"该写的"——因为运行时确实可能没有)。
# 注意: 它不影响"已知一定存在"的访问的便利性吗? 会有些影响:
# - 比如 for 循环里明明不会越界, 也会被标 undefined, 偶尔显得啰嗦。
# - 权衡: 用 .at()、解构、或局部断言处理这些"确定安全"的少数情况,
# 换取"绝大多数索引访问"的类型安全。多数团队认为这个权衡值得。
# 核心: noUncheckedIndexedAccess 让数组/对象索引访问的结果变成 T|undefined,
# 诚实反映"可能越界/不存在"的运行时真相, 强制你判空, 把运行时崩溃提前到编译期。
原来,TypeScript 早就准备好了"说真话"的开关,只是默认没开。默认行为是不安全的:arr[i] 是 T、record[key] 是 T(假装一定有)。而开启 noUncheckedIndexedAccess 后:arr[i] 变成 T | undefined、record[key] 也是——诚实地标出"可能没有",于是你必须先判空/收窄才能用,否则编译报错;这就把"运行时才崩"的隐患,提前到了"编译期就报错"。它的本质是:让类型系统"诚实地反映运行时的真相"——运行时下标访问确实可能返回 undefined,默认 TS 隐瞒了这点(为了便利),这个选项让它说真话;代价是你要写更多判空,但这些判空本就是"该写的"(因为运行时确实可能没有)。当然也有权衡:for 循环里明明不会越界也会被标 undefined、偶尔啰嗦,可以用 .at()、解构或局部断言处理这些"确定安全"的少数情况,换取绝大多数索引访问的类型安全——多数团队认为这个权衡值得。
第二件事:正解——开启严格选项 + 安全的索引访问写法
搞懂了原理,正解就清晰了:开启 noUncheckedIndexedAccess、用判空/可选链/解构/.at() 安全访问、用 Map 的 get 返回明确的可空类型。
// ====== 正解一(根治): tsconfig 开启 noUncheckedIndexedAccess ======
// tsconfig.json:
// { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true } }
// → 之后所有 arr[i] / record[key] 都变 T | undefined, 编译器逼你判空。
// ====== 正解二: 访问后判空 / 用可选链 + 空值合并 ======
const score = scores[10]; // number | undefined
if (score !== undefined) {
console.log(score.toFixed(2)); // ✓ 收窄后安全
}
// 或可选链 + 兜底:
console.log(scores[10]?.toFixed(2) ?? "无数据"); // ✓ 不崩, 给默认
const user = userMap["bob"]; // User | undefined
console.log(user?.name ?? "未知用户"); // ✓ 安全访问 + 兜底
// ====== 正解三: 用 Array.prototype.at()(返回类型本就是 T | undefined)======
const first = scores.at(0); // number | undefined (诚实!)
const last = scores.at(-1); // number | undefined, 还支持负索引
if (last !== undefined) { /* 用 last */ }
// ====== 正解四: 用 Map 代替 Record/对象做"键值查找"======
const map = new Map();
const u = map.get("bob"); // User | undefined (Map.get 本就诚实!)
if (u) { console.log(u.name); }
// → Map.get 的返回类型天生是 T | undefined, 比 Record[key] 的"假装一定有"更安全。
// ====== 正解五: 解构带默认值(处理"可能不存在"的优雅写法)======
const [a = 0, b = 0, c = 0] = scores; // 越界的自动用默认值, 不会 undefined
const { bob = defaultUser } = userMap; // 不存在的用默认
// ====== 正解六: 确实"一定存在"时, 用非空断言(慎用!)======
const known = config.items[0]!; // ! 告诉编译器"我确定它存在"
// → 仅当你【真的确定】时用 !; 用错了它又会变成运行时崩溃(同 as 的风险)。
// 优先用判空, ! 是最后手段。
// 核心: 根治开 noUncheckedIndexedAccess(让索引访问诚实为T|undefined);
// 访问用判空/可选链兜底/.at()/Map.get/解构默认值; 确定存在才慎用非空断言!。
修复的核心,是"让 TypeScript 诚实地标出索引访问可能为 undefined,并用安全的写法访问"。正解一(根治):tsconfig 开启 noUncheckedIndexedAccess——之后所有 arr[i]/record[key] 都变 T | undefined,编译器逼你判空。正解二:访问后判空 / 可选链 + 空值合并——scores[10]?.toFixed(2) ?? "无数据",不崩还给默认。正解三:用 Array.at()——它的返回类型本就是 T | undefined(诚实),还支持负索引。正解四:用 Map 代替 Record/对象做键值查找——Map.get 的返回类型天生是 T | undefined,比 Record[key] 的"假装一定有"更安全。正解五:解构带默认值——const [a = 0] = scores,越界的自动用默认值。正解六:确实"一定存在"时用非空断言 !(慎用)——仅当真的确定时用,用错了又会运行时崩(同 as 的风险),优先判空。归根结底:根治开 noUncheckedIndexedAccess;访问用判空/可选链/.at()/Map.get/解构默认值;确定存在才慎用非空断言。
第三件事:TypeScript 默认的几个"类型不安全"角落
排查后我才知道,索引访问只是 TypeScript 默认状态下"类型不安全"的角落之一。我把这些角落梳理了一遍。
TypeScript 默认状态下的"类型不安全"角落
# 1. 数组/对象索引访问(本文): arr[i] 假装是 T, 实际可能 undefined。
# → 开 noUncheckedIndexedAccess 修复。
# 2. any 的传染: any 类型会"污染"周围, 绕过所有检查。
# → 开 noImplicitAny(禁止隐式any), 别手动用 any(用 unknown)。
# 3. 类型断言 as: 你向编译器"打包票", 它就信, 不做运行时校验。
# → 对外部数据别用 as, 用运行时校验(zod/类型守卫)。
# 4. 函数参数双变性、回调类型不严格:
# → 开 strictFunctionTypes。
# 5. null/undefined 默认可赋给任何类型(老TS):
# → 开 strictNullChecks(strict里含), 让 null/undefined 必须显式处理。
# 6. 未初始化的类字段:
# → 开 strictPropertyInitialization(strict里含)。
# 7. JSON.parse 的返回是 any: 解析的数据完全不受类型保护。
# → 解析后用 zod/类型守卫校验。
# 一个重要认知:
# - TypeScript 的"类型安全", 是【可配置、分等级】的, 不是"开了TS就全安全"。
# - 默认配置 < strict < strict + noUncheckedIndexedAccess + ...
# - 很多"类型不安全"的坑, 不是TS做不到, 而是【默认没开】对应的严格选项。
# - 建议: 新项目直接开 strict + noUncheckedIndexedAccess, 一步到位最严格。
# 核心: TS默认状态有多个类型不安全角落(索引访问/any/as/null/JSON.parse);
# 类型安全是可配置分等级的, 别以为开了TS就全安全, 建议开strict+noUncheckedIndexedAccess。
排查让我意识到,索引访问只是冰山一角。TypeScript 默认状态下还有不少"类型不安全"的角落:索引访问(本文)、any 的传染(开 noImplicitAny、用 unknown 替代)、类型断言 as(对外部数据用运行时校验)、null/undefined 处理(开 strictNullChecks)、未初始化字段(strictPropertyInitialization)、JSON.parse 返回 any(解析后校验)。一个重要认知是:TypeScript 的"类型安全"是可配置、分等级的,不是"开了 TS 就全安全"——默认配置 < strict < strict + noUncheckedIndexedAccess + …;很多"类型不安全"的坑,不是 TS 做不到,而是默认没开对应的严格选项。建议:新项目直接开 strict + noUncheckedIndexedAccess,一步到位最严格。下面这张图,是这次索引访问类型不安全的成因与解法:
第四件事:安全索引访问的几种写法对比
这次踩坑后,我把"安全地访问可能不存在的元素"的几种写法整理成一张表,按场景选。
| 写法 | 结果类型 | 适用 |
|---|---|---|
| arr[i](默认) | T(不安全的假装) | 开严格选项前的坑 |
| arr[i](开了选项) | T | undefined | 推荐,逼你判空 |
| arr.at(i) | T | undefined | 诚实,还支持负索引 |
| arr[i] ?? 默认 | T | 有合理默认值时 |
| arr[i]?.方法() | 结果 | undefined | 访问后调方法 |
| Map.get(key) | T | undefined | 键值查找首选 |
| 解构 [a=默认] | T | 批量取+默认值 |
这张表,把"安全索引访问"的工具摆全了。核心是:让访问的结果类型诚实地体现"可能没有"(T | undefined),然后判空或给默认值;.at() 和 Map.get 天生诚实,是更安全的选择。它给我的启发是:同样是"取一个元素",不同的写法在"类型诚实度"上是有差别的——arr[i](默认)是"乐观但不诚实"的,而 arr.at(i)、Map.get() 是"诚实地承认可能没有"的。这让我体会到一个写代码的细微但重要的取向:在能选择的时候,优先用那些"类型更诚实、更能暴露潜在问题"的 API,而不是"用起来更省事、但掩盖了风险"的 API。因为"类型诚实"的 API,会在编译期就逼你面对"可能没有"这个现实,把潜在的运行时错误,转化为你必须当场处理的编译期提示;这看起来"麻烦了一点"(要多写判空),实则是把"未来某个深夜的线上崩溃",换成了"此刻 IDE 里的一条红线"——这笔交易,无比划算。
第五件事:为什么 strict 模式值得"一步到位"
这次事故也让我下定决心,新项目一律开最严格的配置。我把严格选项的价值梳理了一下。
| 选项 | 挡住的坑 | 代价 |
|---|---|---|
| strictNullChecks | null/undefined 引发的崩溃 | 要显式处理空值 |
| noImplicitAny | 悄悄退化成无类型的 any | 要显式标类型 |
| noUncheckedIndexedAccess | 索引越界/key不存在(本文) | 索引访问要判空 |
| strictFunctionTypes | 函数参数类型不匹配 | 回调类型更严 |
| strictPropertyInitialization | 类字段未初始化 | 字段要初始化 |
这张表,把各个严格选项"挡住什么、代价是什么"摆清了。核心结论是:这些选项的"代价"(多写点判空、多标点类型),换来的是"提前在编译期挡住一大类运行时崩溃"——这是笔极其划算的买卖。它给我的最大启发,是关于"何时引入约束"的思考:很多人(包括曾经的我)倾向于"先用宽松配置快速开发,以后再逐步收紧";但实践证明,这往往是个陷阱——因为"以后"开启严格选项时,会冒出成百上千个历史遗留的报错,改起来痛苦无比,于是"以后"永远不会到来。所以正确的做法是:在项目一开始、代码量还小的时候,就把最严格的配置开起来——让严格的约束,从第一行代码起就"陪着你长大",而不是等长成了庞然大物再痛苦地"纠正"它。这其实是一个更普适的工程智慧:约束(类型检查、Lint、测试)越早引入,成本越低、收益越大;在"地基阶段"立好规矩,远比在"大厦建成后"推倒重来要容易得多。对自己未来的代码质量负责,最好的时机,就是项目的第一天。
第六件事:访问"可能不存在"的数据时,我现在的判断习惯
现在每当我要访问数组元素或对象属性,我都会先想清楚"它真的一定存在吗":
这张图的精髓,是"访问前先问'它一定存在吗',再决定怎么安全访问"。第一问 "这个下标/key 一定存在吗":不确定/外部数据/动态 key 的,一律当成可能 undefined 处理。然后看是否开了 noUncheckedIndexedAccess:开了的编译器已逼你判空、照做即可;没开的强烈建议开启(否则全靠自觉)。访问方式按场景选:有默认值用空值合并 ?? 或解构默认值、要调方法用可选链 ?.、键值查找改用 Map.get。这套习惯,让我访问数据时,从"取了就直接用"变成了"先想它会不会不存在"——核心始终是:下标越界、key 不存在在运行时会返回 undefined,访问"可能不存在"的数据必须显式处理空值。
我立下的几条规矩
这场"number 类型却是 undefined"的事故,换来了我写 TypeScript 时,刻进骨子里的几条铁律:
- 数组/对象索引访问默认是类型不安全的。越界/key不存在运行时返回 undefined,但默认类型不体现。
- 开启 noUncheckedIndexedAccess。让索引访问诚实地变成 T | undefined,逼你判空。
- 访问可能不存在的元素要判空/兜底。可选链 ?. + 空值合并 ?? / 解构默认值。
- 键值查找优先用 Map。Map.get 返回类型天生是 T | undefined,比 Record[key] 安全。
- 非空断言 ! 是最后手段。确定存在才用,用错了又是运行时崩溃。
- TS 类型安全是分等级的。别以为开了 TS 就全安全,默认有多个不安全角落。
- 新项目一步到位开 strict + noUncheckedIndexedAccess。约束越早引入成本越低。
附:开启 noUncheckedIndexedAccess 前后的代码对比
口说无凭。下面用一组对比,让你直观看到这个选项开启前后,代码会如何被强制变安全:
// ============ 开启前(默认): 隐患重重, 但编译全过 ============
function getFirstScoreBad(scores: number[]): string {
const first = scores[0]; // 类型: number(假装一定有)
return first.toFixed(2); // 编译过! 但空数组时运行时崩!
}
getFirstScoreBad([]); // 💥 运行时: Cannot read properties of undefined
function getUserNameBad(map: Record, id: string) {
return map[id].name; // 类型: {name}(假装key一定在), 编译过
} // 💥 id不存在时运行时崩
// ============ 开启后(noUncheckedIndexedAccess): 编译器逼你处理 ============
function getFirstScoreGood(scores: number[]): string {
const first = scores[0]; // 类型: number | undefined(诚实!)
// return first.toFixed(2); // ✗ 编译报错: first 可能 undefined!
if (first === undefined) return "无数据"; // ✓ 必须先处理
return first.toFixed(2); // 此处 first 已收窄为 number, 安全
}
function getUserNameGood(map: Record, id: string) {
const user = map[id]; // 类型: {name} | undefined(诚实!)
return user?.name ?? "未知用户"; // ✓ 可选链 + 兜底, 安全
}
// ============ 真实价值: 它在"重构/改代码"时也保护你 ============
// 假设有人后来改了数据来源, 让某个数组可能为空 ——
// 开启了选项, 所有"假设它非空"的旧代码会【立刻编译报错】, 提醒你去适配。
// 没开选项, 这些地方会"静默地"埋下运行时崩溃, 直到某天线上炸了才发现。
// ============ 配套: 处理"确定安全"的少数情况, 避免啰嗦 ============
// for 循环里确定不越界:
for (let i = 0; i < scores.length; i++) {
const s = scores[i]; // 仍是 number | undefined
if (s === undefined) continue; // 加一行守卫(或用 for...of 遍历值)
// ... 用 s
}
// 更优雅: 直接遍历值, 而非索引
for (const s of scores) { // s 的类型就是 number(遍历值不会undefined)
console.log(s.toFixed(2)); // ✓ 无需判空
}
// 核心: 开启前编译全过却埋运行时崩溃; 开启后索引访问变T|undefined逼你判空,
// 重构时还能立刻揪出"假设非空"的旧代码; 确定安全的用for...of遍历值避免啰嗦。
这组对比,把 noUncheckedIndexedAccess 的价值,变成了肉眼可见的差异。开启前,getFirstScoreBad([]) 这样的代码编译全过、却在空数组时运行时崩溃;开启后,编译器会立刻报错"first 可能 undefined",逼你先判空,把崩溃挡在编译期。而我尤其想强调的,是它"在重构时保护你"的真实价值:假设某天有人改了数据来源、让一个原本非空的数组变得可能为空——开启了这个选项,所有"假设它非空"的旧代码会立刻编译报错,像一张地图一样精确地告诉你"这些地方都需要去适配新情况";而没开选项,这些地方会静默地埋下运行时崩溃,直到某天线上炸了才被发现。这,正是我想用这组对比,留给每个 TypeScript 开发者的最后一课:类型系统最大的价值,不只在于"写代码时帮你查错",更在于"改代码时帮你兜底"——当系统的某个假设发生变化时,一个严格的类型系统,能沿着类型的脉络,自动地、无遗漏地标出所有受影响的地方,让"牵一发而动全身"的修改,变得安全可控。而这种"重构时的安全网",恰恰是在代码越长越大、改动越来越不敢下手时,最宝贵的东西。所以,那些"开启时要多写几行判空"的严格选项,买到的不只是"今天少一个 bug",更是"未来每一次重构时的从容和底气"——这笔投资,会在项目的整个生命周期里,持续地回报你。
写在最后
回头看,这场由"索引访问类型不安全"引发的、number 类型却是 undefined 的事故,真正教给我的,远不止"开个编译选项"这一件事。它让我对 TypeScript、乃至所有"类型系统"的理解,又深了一层。我之前对 TypeScript 有一个模糊的信念:"它是类型安全的,有它把关,就不会有类型错误"。可这次事故让我看清:TypeScript 的"类型安全",不是一个非黑即白的"有"或"无",而是一个可以调节的"光谱";而且,它在默认状态下,为了"使用便利",主动在好几个地方放松了安全性(比如索引访问)。我栽跟头,正是因为我把"默认配置的 TypeScript"当成了"完全类型安全的 TypeScript",信任了一个其实在某些角落"会撒谎"的它。这让我领悟到一个使用任何"安全/保障类工具"时都至关重要的道理:要清楚地知道这个工具"默认的保障级别"是什么,以及"它的保障能调到多严、我需要调到多严";不能想当然地以为"用了它 = 拥有了它能提供的最高级别的保障"。很多工具(类型检查器、Linter、安全扫描器)出于"降低上手门槛""兼容存量"等考虑,默认配置往往是"宽松"的,而它真正的威力,藏在那些"需要你主动开启的严格选项"里。所以,认真读一遍工具的配置项、把它的保障调到与你的需求相匹配的级别——这件"看起来不起眼"的事,常常能帮你避开一大类问题。用好一个工具的前提,是先搞清楚"它默认给了我多少,以及我还能要多少"。这,是我用一次"类型撒谎"的事故,换来的、关于 TypeScript、也关于"工具的保障是分级且可配的"的、最朴素也最深刻的领悟。如果这篇复盘,能让你回去就给 tsconfig 加上 noUncheckedIndexedAccess,那我对着那个"是 number 却是 undefined"的变量熬的这大半天,就值了。
—— 别看了 · 2026