2026-10-03 更新:本文改用 ECMAScript 规范、TypeScript 官方文档与官方 lib 声明文件作为依据重写,删去了原先的本机编译器输出,每条结论都标注了出处,文末汇总来源链接。
一段代码编译通过、上线却在空字符串上抛错,这类事故的根子几乎总是同一处:str[0] 被 TypeScript 推成 string,而运行时它可能是 undefined。这不是编译器"骗人",是运行时语义、lib 声明与编译选项三者共同决定的。本文按规范与官方文档逐层讲清楚,并给出可复制的示例。
一、运行时语义先由 ECMAScript 规范定死
三种取值方式的差别不在 TypeScript,而在规范里:
charAt(i):ECMAScript 规范 22.1.3.2String.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 里还解释了两个"为什么不做得更聪明"的原因,都很实际:
- 对普通
for循环里的e[i]做收窄是不健全的——控制流系统无法追踪循环体内的副作用(比如循环里把数组清空了)。 - 为
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 管不了这一类。
七、实践建议
s[i]与charAt/at不是同一层次的 API:前者依赖索引签名,后两者的可空性写在签名里。代码里需要"越界安全"语义时,优先选后两者。- 打开
noUncheckedIndexedAccess会把一批"看起来没问题"的索引访问暴露成可能undefined。这是它存在的意义,不是噪音;代价是要用判空、提前return或非空断言收敛,而非空断言用多了又等于把检查关回去。 - 表达"固定长度"用元组,别用
T[]——元组越界由 TS2493 兜住,而且字面量下标不会因开关而变成| undefined。 - 关于"字符字面量类型按码元还是码点切分"这类跨版本差异,本文不展开:缺少可以引用的官方 release note 支持,按"没有出处就不写"处理。
八、小结
str[0] 是 string,因为 lib.es5.d.ts 给它声明了数字索引签名;运行时它可能是 undefined,因为 ECMAScript 的 StringGetOwnProperty 是这么规定的。TypeScript 4.1 的 noUncheckedIndexedAccess 提供了修正路径,但它不在 strict 家族里,需要显式打开;而 charAt、at 各自的可空性来自自己的签名,不受它影响。记住这几条,字符串索引的坑基本就堵上了。
参考来源
- ECMAScript Language Specification, 22.1.3.2 String.prototype.charAt ( position ) — https://tc39.es/ecma262/multipage/text-processing.html#sec-string.prototype.charat
- ECMAScript Language Specification, 10.4.3.5 StringGetOwnProperty ( string, propertyKey ) — https://tc39.es/ecma262/multipage/ordinary-and-exotic-objects-behaviours.html#sec-string-exotic-objects-getownproperty
- TypeScript tsconfig Reference, noUncheckedIndexedAccess — https://www.typescriptlang.org/tsconfig/noUncheckedIndexedAccess.html
- TypeScript tsconfig Reference, strict(strict 家族列表)— https://www.typescriptlang.org/tsconfig/strict.html
- TypeScript tsconfig Reference, strictNullChecks — https://www.typescriptlang.org/tsconfig/strictNullChecks.html
- TypeScript 4.1 Release Notes — Checked Indexed Accesses (--noUncheckedIndexedAccess) — https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-1.html
- TypeScript 官方 lib 声明:
src/lib/es5.d.ts(tag v5.9.3)— https://github.com/microsoft/TypeScript/blob/v5.9.3/src/lib/es5.d.ts - TypeScript 官方 lib 声明:
src/lib/es2022.string.d.ts(tag v5.9.3)— https://github.com/microsoft/TypeScript/blob/v5.9.3/src/lib/es2022.string.d.ts - TypeScript 官方诊断信息:
src/compiler/diagnosticMessages.json(TS2493/TS7015/TS2538/TS2542)— https://github.com/microsoft/TypeScript/blob/v5.9.3/src/compiler/diagnosticMessages.json - microsoft/TypeScript PR #39560 — --noUncheckedIndexedAccess — https://github.com/microsoft/TypeScript/pull/39560
- MDN, String.prototype.charAt() — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/charAt
- MDN, String.prototype.at() — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/at
- MDN, String — Character access — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#character_access