--noUncheckedIndexedAccess by RyanCavanaugh · Pull Request #39560 · microsoft/TypeScript
Pedantic Index Signatures noUncheckedIndexedAccess
Implements #13778
This is a draft PR for the purpose of soliciting concrete feedback and discussion based on people trying this out in their code. An npm-installable version will be available soon (watch for the bot).
Given current limitations in CFA we don't have any good ideas on how to fix, we remain guardedly skeptical this feature will be usable in practice. We're quite open to feedback to the contrary and that's why this PR is up - so that people can try out some bits and see how usable it is. This flag is not scheduled for any current TS release and please don't bet your entire coding strategy on it being shipped in a release! Anyway, here's the PR text.
Introduction
Under the flag --pedanticIndexSignatures, the following changes occur:
- Any indexed access expression
obj[index]used in a read position will includeundefinedin its type, unlessindexis a string literal or numeric literal with a previous narrowing in effect - Any property access expression
obj.propwhere no matching declared property namedpropexists, but a string index signature does exist, will includeundefinedin its type, unless a previous narrowing onobj.propis in effect
Other related behaviors are left unchanged:
- Indexed access types, e.g.
type A = SomeType[number], retain their current meaning (undefinedis not added to this type) - Writes to
obj[index]andobj.propforms retain their normal behavior
In practice, it looks like this:
// Get a random-length array, perhaps const arr = [1, 2, 3, 4, 5].slice(Math.random() * 5); // Error, can't assign number | undefined to number const n: number = arr[2]; if (arr[3]) { // OK, previous narrowing based on literal index is in effect arr[3].toFixed(); } // Writes of 'undefined' are disallowed arr[6] = undefined;
And like this:
const obj: { [s: string]: string } = { [(new Date().toString().substr(0, 3))]: "today" }; // Error, maybe it's not Tuesday console.log(obj.Tue.toLowerCase()); if (obj.Fri) { // OK, previous narrowing on 'obj.Fri' in effect console.log(`${obj.Fri.toUpperCase()} IS FRIDAY`); }
Restrictions on narrowing on non-literal indices (see below) mean this feature is not as ergonomic as some other TypeScript behavior, so many common patterns may require extra work:
let arr = [1, 2, 3, 4]; for (let i = 0; i < arr.length; i++) { // Error; 'i' might be out of bounds const n: number = arr[i]; if (arr[i] !== undefined) { // Still an error; dynamic indices do not cause narrowing const m1: number = arr[i]; i++; // This is sometimes a good thing, though! const m2: number = arr[i]; } }
Better patterns include for/of:
for (const el of arr) { // OK; for/of assumes no out-of-bounds problems const n: number = el; }
forEach:
arr.forEach(el => { // OK; forEach won't exceed bounds const n: number = el; });
Or stashing in a constant:
for (let i = 0; i < arr.length; i++) { const el = arr[i]; if (el !== undefined) { // OK; constants may be narrowed console.log(el.toFixed()); } }
If you're targeting a newer environment or have an appropriate polyfill, you can also use Object.entries:
for (const [el, i] of Object.entries(arr)) { console.log(`Element at index ${i} had value ${el.toFixed()}`); }
Other Caveats & Discussion
Naming
Name is up for bikeshedding, subject to the following caveats:
- We do not intend to make this part of the
--strictfamily with its current behavorial limitations, so anything starting withstrictis a no-go- If, in the future, we find a way to make this palatable (i.e. can be turned on with nearly zero false positives), we can make it be an alias for a new
--strictIndexSignaturessetting
- If, in the future, we find a way to make this palatable (i.e. can be turned on with nearly zero false positives), we can make it be an alias for a new
- We don't really think this is a great mode to be in by default, so the name should connote "Here be dragons" in terms of ergonomics
- The behavior changes both string and numeric index behavior, so we'd prefer to avoid
Arrayas part of the name
More on Narrowing (or "Why doesn't my for loop just work?")
A predicted FAQ is why a "normal" C-style for loop is not allowed:
let arr = [1, 2, 3, 4]; for (let i = 0; i < arr.length; i++) { // Error; 'i' might be out of bounds... but it isn't, right? const n: number = arr[i]; }
First, TS's control flow system doesn't track side effects in a sufficiently rich way to account for cases like:
if (arr[i] !== undefined) { arr.length = 0; // Side effect of clearing the array let x: number = arr[i]; // Unsound if allowed }
so it would be incorrectly optimistic to assume this kind of mutation (either to i or arr) hasn't occurred. In practice, in JS there's not much reason to use for instead of for/of unless you're doing something with the index, and that "something" is usually math that could potentially result in an out-of-bounds index.
Second, every narrowing is based on syntactic patterns, and adding the form e[i] to the list of things we narrow on incurred a 5-10% performance penalty. That doesn't sound like much in isolation, but if you and your ten best friends each got a new feature that came with a 5% performance penalty, you probably wouldn't like the fact that the next release of TypeScript was 70% slower than the last one.
Design Point: Sparse Arrays
A sparse array is created in JavaScript whenever you write to an index that is not already in-bounds or one past the current end of the array:
let arr = [0]; arr[4] = 4; // arr is "[0, empty × 3, 4]"
JS behavior around sparse arrays is not the most predictable thing.
for / of
for(const el of arr) console.log(el) // Prints 0, undefined, undefined, undefined, 4
👉 for/of treats a sparse array like an array with filled-in undefineds
forEach
arr.forEach(e => console.log(e)) // Prints 0, 4
👉 forEach skips the sparse elements
... (spread)
const arr2 = [...arr]; arr2.forEach(e => console.log(e)) // Prints 0, undefined, undefined, undefined, 4
👉 When you spread a sparse array, you get a non-sparse version of it.
Given these behaviors, we have decided to just assume that sparse arrays don't exist in well-formed programs. They are extremely rare to encounter in practice, and handling them "correctly" will have some extremely negative side effects. In particular, TS doesn't have a way to change function signatures based on compiler settings, so forEach is always to be passing its element type T (not T | undefined) to the callback. If sparse arrays are assumed to exist, then we need [...arr].forEach(x => x.toFixed()) to issue an error, and the only way to do that is to say that [...arr] produces (number | undefined)[]. This is going to be annoying as heck, because you can't use ! to convert a (T | undefined)[] to a T[]. Given how common patterns like const copy = [...someArr] are for working with arrays, this does not seem palatable even for the most strict-enthusiast programmers.
Design Point: What's the type of T[number] / T[string] ?
A question when implementing this feature is given this type:
type Q = (boolean[])[number];
Should Q be boolean (the type that's legal to write to the array) or boolean | undefined (the type you'd get if you read from the array)?
Inspecting this code:
// N.B. not a good function signature; don't do this in real code function zzz<T extends unknown[]>(arr: T, i: number): T[number] { return arr[i]; }
It feels like the job of the checker in this mode is to say "zzz does a possibly out-of-bounds read and needs clarification". You could then write either of these instead:
// Allows out-of-bounds access to silently occur function zzz1<T extends unknown[]>(arr: T, i: number): T[number] | undefined { return arr[i]; } // - or - // Bounds checks for you function zzz2<T extends unknown[]>(arr: T, i: number): T[number] { // TODO: Validate that 'i' is in bounds return arr[i]!; // ! - safety not guaranteed but we tried }
This has the very positive side effect that it means T[number] in a declaration file doesn't change meaning based on whether the flag is set, and maintains the identity that
type A = { [n: number]: B }[number] // A === B