3

Let's say that there is a type:


interface ObjWithKnownKeys {
  prop: string;
}

And I need an object that contains objects of this type. I know that I can type it like this:

interface ObjWithUnknownKeysA {
  [key: string]: ObjWithKnownKeys;
}

const a: ObjWithUnknownKeysA = {};

// ❌ no error that 'someName' is possibly undefined
a.someName.prop;

for (const key in a) {
  const entry = a[key];
  // ✔️ no errors
  entry.prop;
}
for (const [key, entry] of Object.entries(a)) {
  // ✔️ no errors
  entry.prop;
}

Or like this:

interface ObjWithUnknownKeysB {
  [key: string]: ObjWithKnownKeys | undefined;
}

const b: ObjWithUnknownKeysB = {};

// ✔️ error that 'someName' is possibly undefined
b.someName.prop;

for (const key in b) {
  const entry = b[key];
  // ❌ error that entry is possibly undefined (but it's clear it's not for given key)
  entry.prop;
}
for (const [key, entry] of Object.entries(b)) {
  // ❌ error that entry is possibly undefined
  entry.prop;
}

But how should I type it to make sure that it will always work as expected?

EDIT AFTER AUTO CLOSE: There is no mention of --noUncheckedIndexedAccess flag in that thread that was marked as similar. I think this flag is a better answer.

GreenTea222
  • 195
  • 3
  • 11
  • _"no error that 'someName' is possibly undefined"_ - why would there be? You said every string key would map to an `ObjWithKnownKeys`. For the second part see the dupe. – jonrsharpe Mar 11 '22 at 15:44
  • 1
    This is just one of those issues in TS that has no perfect solution. You could turn on [the `--noUncheckedIndexedAccess` compiler flag](//www.typescriptlang.org/docs/handbook/release-notes/typescript-4-1.html#checked-indexed-accesses---nouncheckedindexedaccess) which would give a different set of good/bad behavior, see [this playground link](//tsplay.dev/N7PYnm). The compiler can't accurately track exactly which keys are known to be present and which aren't, so you have to pick some approximation. Does that address your question? If so I could write up an answer; if not, what am I missing? – jcalz Mar 11 '22 at 15:50
  • @jonrsharpe "every string key would map to an `ObjWithKnownKeys`". That's not what an [index signature](https://www.typescriptlang.org/docs/handbook/2/objects.html#index-signatures) means (which is a good thing, because such a type would be impossible to implement without a `Proxy`). An index signature of the form `{[k: KeyType]: ValType}` means that *if* there is a property with a key of type `KeyType`, *then* it will have a value of type `ValType`. And therefore it is technically unsafe to let you just index into it with an arbitrary key of type `KeyType`. – jcalz Mar 11 '22 at 15:54
  • Indeed, the `--noUncheckedIndexedAccess` compiler flag was introduced to close this safety hole for those who felt it was very important. The problem is that TypeScript doesn't have a perfect solution, so turning on that flag makes the compiler change from *optimistic* ("sure, I guess if they're writing `obj.randomKey` then there's probably a property there) to *pessimistic* ("hey, you'd better check for `undefined` in that `for`...`in` loop"). You can choose between convenient but unsafe, or safe but annoying. – jcalz Mar 11 '22 at 15:57
  • Yep. This ----noUncheckedIndexedAccess makes perfect sense to me (almost). When you think about it if you you're using a for .. in loop, if you define the iterator as 'let/var', it's perfectly resonable to assume that a value could have been changed unintentionaly. I just don't understand why it doesn't know that in case of 'const' that is not the case. – GreenTea222 Mar 11 '22 at 15:58
  • @jcalz I'm not entirely sure what distinction you're trying to draw. It's indeed actually unsafe to assume _every_ property has a value, in the same way that it's unsafe to assume that _every_ array index has one, but that's what the compiler gives you (absent, as you note, [`noUncheckedIndexedAccess`](https://www.typescriptlang.org/tsconfig#noUncheckedIndexedAccess)). – jonrsharpe Mar 11 '22 at 15:58
  • @jonrsharpe Hmm, when you said "'no error that 'someName' is possibly undefined' - why would there be?", what point were you making? Hopefully not that the literal string `"someName"` is always defined. Assuming you were saying that there's no reason for the compiler to warn you that `a.someName` might be `undefined`, can you articulate why not? Maybe I misunderstood you. – jcalz Mar 11 '22 at 16:00
  • @GreenTea222 It's not that the compiler really thinks the key could have mutated, it's just that it only tracks *types* and not *identity* of a variable. When you write `for (const k in obj)`, `k` has the type `string`. There is no concept of `k` being "a `string` we can exempt from the `--noUncheckedIndexedAccess` check for `obj`". That would require the compiler either track the identities of `k` and `obj`, or automatically synthesize generic type parameters (e.g., `k` is of some new type `K extends string` and `obj` is narrowed to `Record`), none of which happens. – jcalz Mar 11 '22 at 16:07
  • @jcalz _"Hopefully not that the literal string "someName" is always defined."_ - what? Those quotes were in the OP's original that _I_ was quoting. But I was saying there's no reason for the compiler to tell you `a.someName` might be `undefined`, because the index signature tells it otherwise. All it can infer is that any property will exist and have a value, and that value will be an `ObjWithKnownKeys`. Is that actually plausible? No, of course not, but that's all the information the compiler has. What you're talking about (_"if there is a property..."_) isn't known at compile time. – jonrsharpe Mar 11 '22 at 16:09
  • @GreenTea222 so it's just a limitation of TypeScript, and there's not much to be done other than pick some workaround and move on with your life. There's a similar issue at [microsoft/TypeScript#32693](https://github.com/microsoft/TypeScript/issues/32693) where the compiler can't understand that `foo[k] = bar[k]` should be acceptable when `foo` and `bar` are of the same type `T` and where `k` is of type `keyof T`. – jcalz Mar 11 '22 at 16:12
  • I'm still learning TypeScript so these limitations are quite surprising to me, though. I just learned that JSX is not type safe at all in react for example. I used to code in C++ a lot, and types were always rock solid so these good-enough types make me a bit anxious Although TypeScript gives more freedom so I guess it's a fair exchange. – GreenTea222 Mar 11 '22 at 16:18
  • @jonrsharpe I think we probably agree on the facts but have differing interpretations. In my view there *is* a reason for the compiler to tell you that `a.someName` might be `undefined`; the reason is that `a.someName` might, in fact, be `undefined`. I don't see an index signature as "telling the compiler that every `string`-keyed property will actually be defined"; the compiler makes such a simplifying assumption, but that depends on compiler settings and compiler implementation, and has relatively little to do with the meaning of index signatures. – jcalz Mar 11 '22 at 16:23
  • @jonrsharpe If all you meant was "the compiler makes this assumption, so it doesn't warn", fine, but it seems weird to question why someone unfamiliar with this implementation would expect otherwise, especially given the scads of GitHub issues filed about this very topic before `--noUncheckedIndexedAccess` was introduced. "Why would there be a warning? You told it not to warn you" is, in my opinion, a strange take on this when talking to someone who doesn't already know how the compiler works. – jcalz Mar 11 '22 at 16:23
  • @GreenTea222 so would you like me to write up an answer explaining this situation? Or am I still missing something about the issue? – jcalz Mar 11 '22 at 16:23
  • You can answer once it's open again, it was auto closed for some reason. – GreenTea222 Mar 11 '22 at 16:27
  • I voted to reopen, since I don't think this is a duplicate of the other one either. The issue there is that calling `Object.keys(obj)` when `obj` is of type (say) `{a: string}` gives `string[]` instead of `"a"[]`. Here you already have a string index signature, so you probably are happy that `Object.keys(obj)` gives you `string[]`. I don't see you asking about the *key*s at all; you're asking about the *values* and why they are not possibly `undefined`. – jcalz Mar 11 '22 at 16:30
  • Yes. Although, the other topic is something I'll have to research too. – GreenTea222 Mar 11 '22 at 16:34

1 Answers1

5

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

jcalz
  • 264,269
  • 27
  • 359
  • 360