typescriptfor-in-loop

Why for...in with an object in typescript is typed as string


I hope this is not a silly question. Why when I use for (let property in myObject)... property is recognized by typescript as "string" instead of "keyof typeof myObject"? Is there a way or setting to use the for...in to iterate over an object in a typesafe way?

Important: this question is very similar to this one, I understand if it might be considered a duplicate. I decided to post a new question because the question is quite old, and it does not addresses the case of a constant object or with a defined type declared as const. Many of its answers address the usage of variable objects or arrays, and only one (the most recent one) addresses iterating over an object like I need. But I don't know how his answer, compared to the alternatives I was trying to implement compare, so I wanted to focus on this specific use case.

For example, this is perfectly valid code in Javascript

let myObject = {
    a: "propA",
    b: "propB"
}

for (let prop in myObject){
    console.log(`${prop}: ${myObject[prop]}`);
    // prints "a: propA", "b: propB"
}

But in typescript its marked as invalid, and throws the following error: "Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ a: string; b: string; }'."

I don't understand why. As I understand it, prop should be typed as "a" | "b" because the for...in iterates over the properties of an object, so the type system should expect no other values going there.

I read this SO post which explain that because the object accessor can be pretty much anything, then typescript types it as string, but if I have a constant, or declare it as const I expect the properties to be inferred, yet it makes no difference. I don't know if I am missing something

That post also has one answer that states that to use for...in you can use Object.prototype.hasOwnProperty.call(obj, key) to validate if the property exists, but I don't know if this is the best way and also it looks kind of redundant.

I tried declaring a type beforehand as type myObjectProps = keyof typeof myObject; but I found that "The left-hand side of a 'for...in' statement cannot use a type annotation."

The cleanest solution I found is to cast the property as follows

for (let prop in myObject){
    console.log(`${prop}: ${myObject[prop as myObjectProps]}`)
}

But I don't know if its the proper way to do it, or if its something safe to do in the first place, or there is in fact a situation that can happen in which a prop will come from myObject but will not be accessible through object[prop]?


Solution

  • While it's clear why the key is string (types in TS are open, so at runtime could be polluted unexpectedly, even I polluted DOM prototypes with iterable properties), you could use a branded type to make the keys more specific, but you could still encounter runtime errors.

    As a result TS tells you that iterating keys isn't safe in general and that's a good thing.

    Playground

    type Sealed = { _sealed?: never };
    
    interface ObjectConstructor {
        seal<T>(obj: T): asserts obj is T & Sealed;
        iterateKeys<T>(obj: T): T extends Sealed ? IterableIterator<Exclude<keyof T, '_sealed'>> : IterableIterator<string>;
    }
    
    Object.defineProperty(Object, 'iterateKeys', {
        value: function* (obj: object) {
            for (const key of Object.keys(obj)) {
                yield key;
            }
        }
    });
    
    // Usage
    
    const myObject = { key1: 1, key2: 2 };
    
    // Type before sealing
    for (const prop of Object.iterateKeys(myObject)) {
        // `prop` is string
        console.log(`${prop}: ${myObject[prop as keyof typeof myObject]}`);
    }
    
    Object.seal(myObject);
    
    // Type after sealing
    for (const prop of Object.iterateKeys(myObject)) {
        // `prop` is now inferred as keyof typeof myObject
        console.log(`${prop}: ${myObject[prop]}`);
    }
    
    function makeSomething<T extends typeof myObject>(obj: T){
        Object.seal(obj);
        for(const prop of Object.iterateKeys(obj)){
            obj3[prop] = 2// OOPS const prop: "key1" | "key2"
        }
    }
    
    const obj2 = {key1: 1, key2: 2, key3:3};
    const obj3 = {key1: 1, key2: 2};
    Object.seal(obj3);
    makeSomething(obj2);