Reputation: 233
The challenge: safely and simply iterating over an object and access properties using bracket notation with TypeScript.
The best solution I've found so far is the following, but I'd love to simplify further:
for (const key of propList) {
if (!(key in propList)) continue; // Required to avoid: for (... in ...) statements must be filtered with an if statement
const safeKey = key as keyof typeof myObj;
console.log(myObj[safeKey]);
}
I'm looking for something simpler along the lines of (this is invalid code, just trying to express my goal to eliminate the redundant const):
for (const (safeKey keyof typeof myObj) of propList) {
console.log(myObj[safeKey]);
}
I've considered these, but don't think they are desireable:
// Undesirable option #1 (potentially requires repetitive type assertions)
for (const key in myObj) {
console.log(myObj[key as keyof typeof myObj]); //works, but have to repeat the type assertion for every use
}
//Undesirable option #2 (creates scope issues)
let key: keyof typeof myObj;
for (key in myObj) {
console.log(myObj[key]); //works, but may create scope issues requiring a closure (such as if using this pattern to create a bunch of buttons with click handlers)
}
And I've considered these, but they are all invalid or incomplete solutions:
//Invalid option A
const myObj = { prop1: true, prop2: true };
for (const key in myObj) {
console.log(myObj[key]); //TS error: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ prop1: boolean; prop2: boolean; }.
}
// Invalid option B
const myObj = { prop1: true, prop2: true };
for (const key in myObj) {
if (key in myObj) console.log(myObj[key]); //TS still treats key as a string
}
// Invalid option C
for (const key in myObj) {
if (myObj.hasOwnProperty(key)) console.log(myObj[key]); //TS still treats key as a string
}
// Invalid option D
const propList = Object.getOwnPropertyNames(myObj);
for (const key of propList) {
console.log(myObj[key]); //TS treats key as a string because .getOwnPropertyNames() returns string[]
}
// Invalid option E
Object.keys(myObj).forEach((key) => {
console.log(myObj[key]); //TS treats key as a string because .keys() returns string[]
});
//Invalid option F (incomplete: can get, but not set values)
for (const [key, val] of Object.entries(myObj)) {
myObj[key] = false; //TS treats key as a string
}
Thanks for any help!
Upvotes: 3
Views: 7549
Reputation: 328142
TL;DR: Object types are not exact (excess property checks notwithstanding), so iterating properties loses type guarantees unless you provide such guarantees, meaning that you take responsibility if the guarantee is not met... so be careful!
You will need to use a type assertion (or the moral equivalent) to tell the compiler that the object only has the keys it knows about, and there are caveats which make it possible (but maybe not likely) for this assumption to be wrong and lead to runtime errors.
Object types in TypeScript are open or extendible, and not closed or exact (see microsoft/TypeScript#12936 for a feature request to support exact types). The compiler does not assume the properties it knows about are the only properties that might exist on the object. For example, the type { prop1: boolean; prop2: boolean; }
means "there are definitely boolean
-valued properties at the prop1
and prop2
keys", but does not mean "no keys except prop1
and prop2
will exist on this object".
This is very useful for JavaScript because it allows us to treat class
hierarchies as type hierarchies. If class Bar extends Foo { extraProp = 123 }
then I know any instance of Bar
is also an instance of Foo
. But if object types in TypeScript were exact, then the extra extraProp
property in Bar
would prevent you from assigning a value of type Bar
to a variable of type Foo
, thus breaking inheritance. TypeScript's entire structural type system is built around treating object types as being open. See the handbook's example for interface
:
Notice that our object actually has more properties than this, but the compiler only checks that at least the ones required are present and match the types required.
Unfortunately, there are many situations in which people expect object types to be treated as closed. The most obvious one is when you're dealing with an object literal which has not been modified before being processed. In cases where you directly use an object literal in a place where the type checker expects some particular object type, you will get a warning on an "extra" property; this is called excess property checking:
interface MyObj {
prop1: boolean;
prop2: boolean;
}
const myObj: MyObj = { prop1: true, prop2: false, prop3: "oops" }; // error!
// ---------------------------------------------> ~~~~~~~~~~~~~
// Object literal may only specify known properties,
// but 'prop3' does not exist in type 'MyObj'
But that nod to exact types doesn't go very far. All you have to do is use the object literal indirectly; say, by first assigning it to a variable whose type is inferred:
const someObj = { prop1: true, prop2: false, prop3: "oops" };
const myObj: MyObj = someObj; // no error
So with little exception, object types in TypeScript are not treated as exact.
That leads us to iterating through an object's properties. Since object types are not exact, TypeScript considers that any object whose properties you iterate might have unexpected surprises lurking in it. It is the intended and expected behavior, although it is often frustrating. See Why doesn't Object.keys return a keyof type in TypeScript? for more info. The keys of any particular object will be just seen as string
or string | number | symbol
or some very wide thing. It doesn't help to try to say "prop1" | "prop2" | string
because that's just string
.
And if the key is of type string
, then the compiler will warn you when you try to actually index into the object with it, unless that object happens to have a string
index signature. The warning is basically telling you that it can't guarantee what property type, if any, you will see. If you are looking at some unknown extra property, the property type could literally be anything, and thus it is implicitly considered any
. If you are using --noImplicitAny
or the --strict
compiler options, such implicit any
s (anies
?) are considered to be an error.
So you will need some kind of workaround to tell the compiler "I don't care if you think the object isn't exact, the keys of obj
are keyof typeof obj
!". You went through some of them above, but they were too redundant for you. Another workaround is to make a generic helper function whose implementation just calls Object.keys()
, but whose return type you assert to be Array<keyof typeof obj>
:
function keys<T extends object>(obj: T) {
return Object.keys(obj) as Array<keyof T>;
}
Armed with this keys()
function, your iteration can be written with no compile errors:
for (const key of keys(myObj)) {
console.log(myObj[key]); // works now
}
So that will probably work for you, as long as you are sure that your object doesn't have any hidden keys lurking in the bushes ready to jump out at you. For an object literal that you create yourself and immediately iterate without modifying, this is a pretty safe bet. For anything else, there could be nasty errors waiting in store for you at runtime.
It's hard to come up with something that is too horrible if you accidentally treat a property as being a boolean
when it isn't one (other than checks like === true
or === false
, most values can be treated as truthy/falsy without problems). So let's change the object type a bit:
interface YourObj {
x: string;
y: string;
}
function processYourObj(yourObj: YourObj) {
for (const key of keys(yourObj)) {
console.log(yourObj[key].toUpperCase())
}
}
processYourObj({ x: "hello", y: "there" }); // HELLO THERE
That works just fine. But remember, object types are not exact:
const theirObj = { x: "assumption", y: "is", z: false }
processYourObj(theirObj); // no compiler error, but at runtime:
// ASSUMPTION IS 💥 yourObj[key].toUpperCase is not a function
The object theirObj
is assignable to YourObj
because object types are not exact. And so processYourObj()
accepts theirObj
with no compiler error. And at runtime, it tries to take access the toUpperCase()
method of false
, and your program crashes.
Upvotes: 11