--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 include undefined in its type, unless index is a string literal or numeric literal with a previous narrowing in effect
  • Any property access expression obj.prop where no matching declared property named prop exists, but a string index signature does exist, will include undefined in its type, unless a previous narrowing on obj.prop is in effect

Other related behaviors are left unchanged:

  • Indexed access types, e.g. type A = SomeType[number], retain their current meaning (undefined is not added to this type)
  • Writes to obj[index] and obj.prop forms 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 --strict family with its current behavorial limitations, so anything starting with strict is 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 --strictIndexSignatures setting
  • 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 Array as 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