This is one of the pain points in TypeScript. By default, the compiler treats types with index signatures as if every possible property of the relevant key type is present and defined. This is convenient since you don't have to convince the compiler that a property is actually defined before using it:
interface StringIndex {
[k: string]: string;
}
const str: StringIndex = { abc: "hello" };
str.abc.toUpperCase(); // okay
And you can easily iterate over keys/values:
for (const k in str) {
str[k].toUpperCase(); // okay
}
for (const v of Object.values(str)) {
v.toUpperCase(); // okay
}
// arrays have numeric index signatures
const arr: Array<string> = ["x", "y", "z"];
for (const s of arr) {
s.toUpperCase(); // okay
}
Unfortunately, it's demonstrably unsafe:
str.boop.toUpperCase(); // no compiler error!
As such, there was a longstanding feature request at microsoft/TypeScript#13778 for the compiler to see that reading from index signature could give you undefined
. This was quite heavily upvoted by the developer community.
For a long time, if you wanted safety, you could only manually add | undefined
to the property value type. This makes things a lot safer, but now you have to keep telling the compiler that your properties are defined, and you are also allowed to write undefined
to a property:
interface StringIndex {
[k: string]: string | undefined; // manually add undefined
}
const str: StringIndex = { abc: "hello" };
str.abc.toUpperCase(); // error! possibly undefined
str.abc = undefined; // no error, but we don't want to allow this
And iterating is also polluted with undefined
:
for (const k in str) {
str[k].toUpperCase(); // error! ossibly undefined
}
for (const v of Object.values(str)) {
v.toUpperCase(); // error! ossibly undefined
}
const arr: Array<string | undefined> = ["x", "y", "z"];
for (const s of arr) {
s.toUpperCase(); // error! possibly undefined
}
Which is annoying.
Eventually, TypeScript introduced the --noUncheckedIndexedAccess
compiler option, which automatically adds undefined
to the type of properties you read from index signature keys, but doesn't let you write undefined
to it. And for iterating it doesn't add undefined
in for...of
loops or in Object.values()
/Object.entries()
:
// enable --noUncheckedIndexedAccess
interface StringIndex {
[k: string]: string;
}
const str: StringIndex = { abc: "hello" };
str.boop.toUpperCase(); // error! possibly undefined
str.abc = undefined; // error!
for (const v of Object.values(str)) {
v.toUpperCase(); // okay
}
const arr: Array<string> = ["x", "y", "z"];
for (const s of arr) {
s.toUpperCase(); // okay
}
But it's not perfect. It still doesn't recognize that a "known" key is present:
const str: StringIndex = { abc: "hello" };
str.abc.toUpperCase(); // error! still possibly undefined
It doesn't let you iterate over keys with for...in
loops without taking account of undefined
:
for (const k in str) {
str[k].toUpperCase(); // error, oops
}
So you get a different set of desirable/undesirable behavior with --noUncheckedIndexedAccess
, but you never get "the right thing in all circumstances".
The reason why this happens is because the compiler doesn't track the identity of variables; it tracks their types. It can narrow the type of a variable, but it doesn't have a way to tag a variable as a "known present key of such-and-such object". The compiler simply doesn't know how to "do the right thing". Turning on --noUncheckedIndexedAccess
makes things more annoying in general, and people start working around it instead of paying attention to it. That's why it is not part of the --strict
suite of compiler options. From a comment on microsoft/TypeScript#13778:
Think of the two types of keys in the world: Those which you know do have a corresponding property in some object (safe), those which you don't know to have a corresponding property in some object (dangerous). You get the first kind of key, a "safe" key, by writing correct code like [a for
loop]. You get the second kind from key, the "dangerous" kind, from things like user inputs, or random JSON files from disk, or some list of keys which may be present but might not be.
So if you have a key of the dangerous kind and index by it, it'd be nice to have | undefined
in here. But the proposal isn't "Treat dangerous keys as dangerous", it's "Treat all keys, even safe ones, as dangerous". And once you start treating safe keys as dangerous, life really sucks.
You write code like
for (let i = 0; i < arr.length; i++) { console.log(arr[i].name); }
and TypeScript is complaining at you that arr[i]
might be undefined
even though hey look I just @#%#ing tested for it. Now you get in the habit of writing code like this [with a non-null assertion], and it feels stupid:
for (let i = 0; i < arr.length; i++) { console.log(arr[i]!.name); }
Or maybe you write code like this:
function doSomething(myObj: T, yourObj: T) {
for (const k of Object.keys(myObj)) { console.log(yourObj[k].name); }
}
and TypeScript says "Hey, that index expression might be | undefined
, so you dutifully "fix it" because you've seen this error 800 times already:
function doSomething(myObj: T, yourObj: T) {
for (const k of Object.keys(myObj)) { console.log(yourObj[k]!.name); }
}
But you didn't fix the bug. You meant to write Object.keys(yourObj)
, or maybe myObj[k]
. That's the worst kind of compiler error, because it's not actually helping you in any scenario - it's only applying the same ritual to every kind of expression, without regard for whether or not it was actually more dangerous than any other expression of the same form.
I think of the old "Are you sure you want to delete this file?" dialog. If that dialog appeared every time you tried to delete a file, you would very quickly learn to hit del y
when you used to hit del
, and your chances of not deleting something important reset to the pre-dialog baseline. If instead the dialog only appeared when you were deleting files when they weren't going to the recycling bin, now you have meaningful safety. But we have no idea (nor could we) whether your object keys are safe or not, so showing the "Are you sure you want to index that object?" dialog every time you do it isn't likely to find bugs at a better rate than not showing it all.
So because the compiler can't tell the difference between "safe" keys and "dangerous" keys, all you can do is treat all keys the same and decide which failure mode you hate less. The language default is that false negatives are a lesser evil than false positives. If you disagree you can turn on --noUncheckedIndexedAccess
or add | undefined
manually to individual types. But, at least in TS4.6, there's nothing better.
Playground link to code, --noUncheckedIndexedAccess
turned off
Playground link to code, --noUncheckedIndexedAccess
turned on