Robert T
Robert T

Reputation: 233

Simplest way to iterate over an object when using TypeScript?

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

Answers (1)

jcalz
jcalz

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 are not exact

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.


(excess property checks notwithstanding)

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.


so iterating properties loses type guarantees

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 anys (anies?) are considered to be an error.


unless you provide such guarantees

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
}

meaning that you take responsibility if the guarantee is not met

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.


So be careful!

Playground link to code

Upvotes: 11

Related Questions