TypeScript 字符串索引的类型陷阱:为什么 str[0] 不是 string | undefined

2026-06-14 编程开发

2026-10-03 更新:本文改用 ECMAScript 规范、TypeScript 官方文档与官方 lib 声明文件作为依据重写,删去了原先的本机编译器输出,每条结论都标注了出处,文末汇总来源链接。

一段代码编译通过、上线却在空字符串上抛错,这类事故的根子几乎总是同一处:str[0] 被 TypeScript 推成 string,而运行时它可能是 undefined。这不是编译器"骗人",是运行时语义、lib 声明与编译选项三者共同决定的。本文按规范与官方文档逐层讲清楚,并给出可复制的示例。

一、运行时语义先由 ECMAScript 规范定死

三种取值方式的差别不在 TypeScript,而在规范里:

  • charAt(i):ECMAScript 规范 22.1.3.2 String.prototype.charAt ( position ) 规定,若下标小于 0 或不小于字符串长度,返回空字符串——"If there is no element at that index, the result is the empty String"。
  • str[i](属性访问):字符串是 exotic object,数字属性的取值走抽象操作 StringGetOwnProperty(规范 10.4.3.5)。规范写明:若 numericIndex 不是整数字符串、为 -0 或小于 -0、或者 ℝ(numericIndex) ≥ length,则返回 undefined。
  • at(i):MDN 官方文档对 String.prototype.at() 的说明是"Returns undefined if the given index cannot be found",并支持负数下标从末尾反向计数。

所以"越界拿到 undefined"是规范规定的既定行为。真正的问题是:为什么静态类型没跟上。

二、根源:lib.es5.d.ts 里的一行索引签名

TypeScript 的标准库声明文件 src/lib/es5.d.ts 里,interface String 大致是这样(节选,来源见文末):

interface String {
  /** Returns the character at the specified index. */
  charAt(pos: number): string;
  // …其余方法省略…
  readonly [index: number]: string;
}
  • readonly [index: number]: string 是数字索引签名。TypeScript 对"通过数字下标去索引"的表达式就按它推导类型,于是 s[i] 得到 string。
  • charAt(pos: number): string 声明返回 string,不含 undefined——这与运行时"越界返回空串"是一致的,所以它本来就不需要额外保护。
  • at 的声明在 src/lib/es2022.string.d.ts 里:at(index: number): string | undefined;。它的可空性来自函数签名本身,与任何编译选项无关。

⚠️ 官方 lib 文件的目录结构随版本变动(主分支上游已重构,src/lib/es5.d.ts 这个路径在部分版本会 404)。引用时按 tag 取,例如 v5.9.3 的 src/lib/es5.d.ts,不要引用 main 分支的路径。

三、noUncheckedIndexedAccess:TypeScript 4.1 给的那把开关

官方 tsconfig 参考对这个选项的定义是一句话:"Turning on noUncheckedIndexedAccess will add undefined to any un-declared field in the type."(开启后,任何未经显式声明的字段在类型里都会加上 undefined。)

它由 TypeScript 4.1 引入,4.1 的 release notes 专门有一节 Checked Indexed Accesses,动机写得很坦率:旧的乐观行为让人觉得"没用上 strictNullChecks 的完整严格检查"。开启后,每次属性访问或索引访问都被视为可能为 undefined,官方例子里的 opts.yadda 从 string | number 变成 string | number | undefined。

有两点经常被误解:

  • 它不属于 strict 家族。tsconfig 参考里 strict 的 family 列表是 alwaysStrict、strictNullChecks、strictBindCallApply、strictBuiltinIteratorReturn、strictFunctionTypes、strictPropertyInitialization、noImplicitAny、noImplicitThis、useUnknownInCatchVariables——里面没有 noUncheckedIndexedAccess。所以 "strict": true 不会顺手打开它。
  • 实现这个特性的官方 PR(microsoft/TypeScript#39560)明确说明:不打算把它放进 --strict 家族(因为当时的行为有局限),名字也不以 strict 开头。

四、开关打开之后,具体改变什么

规则其实只有一条,但有一条重要的例外:

  • 读位置的索引访问表达式 obj[index] 会在类型里带上 undefined;
  • 除非 index 是字符串或数字字面量(PR #39560 原文表述:unless index is a string literal or numeric literal)。

这就解释了一个常见现象:数组的变量下标被加上 undefined,而写死的 arr[0] 不会。

元组越界是另一套机制,与这个开关无关:官方诊断信息文件里定义了错误码 TS2493——Tuple type '{0}' of length '{1}' has no element at index '{2}'. 也就是说,元组越界在默认配置下就会报错。

PR 里还解释了两个"为什么不做得更聪明"的原因,都很实际:

  1. 对普通 for 循环里的 e[i] 做收窄是不健全的——控制流系统无法追踪循环体内的副作用(比如循环里把数组清空了)。
  2. 为 e[i] 增加收窄会带来约 5–10% 的编译期性能开销,所以 TS 只对语法上能确定的表达式做收窄。

可复制的最小示例:

declare const arr: number[];
declare const tuple: [string, number];

// 数组:默认 number;开启 noUncheckedIndexedAccess 后变成 number | undefined
const x = arr[0];

// 元组 + 字面量下标:不追加 undefined
const y: string = tuple[0];

// 元组越界:默认配置就报错(TS2493)
const z = tuple[2];
// error TS2493: Tuple type '[string, number]' of length '2' has no element at index '2'.

五、为什么 charAt 与 at 不受这个开关影响

因为它们的类型来自函数声明,而不是索引签名。开关改变的是"按索引签名推导"的那类表达式:

declare const s: string;

const a: string = s[0];          // 索引签名:默认推成 string(开启开关后为 string | undefined)
const b: string = s.charAt(0);   // 声明返回 string,与运行时“越界返回空串”一致
const c: string = s.at(0);       // 声明返回 string | undefined —— 需要判空

想要"越界返回空串"就用 charAt,想要"越界返回 undefined"就用 at。两者都比裸的 s[i] 更能表达运行时的真实语义。

六、与索引有关的另外几个报错

除了 TS2493,官方诊断信息文件里还有几条值得记住的:

  • TS7015 — Element implicitly has an 'any' type because index expression is not of type 'number'.(用非数字下标去索引数组/字符串)
  • TS2538 — Type '{0}' cannot be used as an index type.(下标类型不合法)
  • TS2542 — Index signature in type '{0}' only permits reading.(对只读索引签名赋值)

它们的共同点是:问题出在下标表达式的类型,而不是取值结果的可空性——noUncheckedIndexedAccess 管不了这一类。

七、实践建议

  1. s[i] 与 charAt/at 不是同一层次的 API:前者依赖索引签名,后两者的可空性写在签名里。代码里需要"越界安全"语义时,优先选后两者。
  2. 打开 noUncheckedIndexedAccess 会把一批"看起来没问题"的索引访问暴露成可能 undefined。这是它存在的意义,不是噪音;代价是要用判空、提前 return 或非空断言收敛,而非空断言用多了又等于把检查关回去。
  3. 表达"固定长度"用元组,别用 T[]——元组越界由 TS2493 兜住,而且字面量下标不会因开关而变成 | undefined。
  4. 关于"字符字面量类型按码元还是码点切分"这类跨版本差异,本文不展开:缺少可以引用的官方 release note 支持,按"没有出处就不写"处理。

八、小结

str[0] 是 string,因为 lib.es5.d.ts 给它声明了数字索引签名;运行时它可能是 undefined,因为 ECMAScript 的 StringGetOwnProperty 是这么规定的。TypeScript 4.1 的 noUncheckedIndexedAccess 提供了修正路径,但它不在 strict 家族里,需要显式打开;而 charAt、at 各自的可空性来自自己的签名,不受它影响。记住这几条,字符串索引的坑基本就堵上了。

参考来源