Search code examples
typescript

Can I narrow keys of objects when converting them from one to another?


Here the example of code.

enum Keys {
    Key1 = 'key1',
    Key2 = 'key2',
}

type A = {
    [Keys.Key1]?: {
        value: string;
    };
    [Keys.Key2]?: {
        value: number;
    };
};

type B = {
    [Keys.Key1]?: string;
    [Keys.Key2]?: number;
};

function func(obj: A): B {
    return Object.values(Keys).reduce(
        (prev, key) => {
            if (key in obj) {
                prev[key] = obj[key].value;
            }
            return prev;
        },
        <B>{},
    );
}

It shows an error on line prev[key] = obj[key].value;

Type 'string | number' is not assignable to type 'undefined'.
  Type 'string' is not assignable to type 'undefined'.ts(2322)

Of cource I can do this:

prev[key] = (obj[key] as any).value;

Or narrow types by definition through code:

function func(obj: A): B {
    return Object.values(Keys).reduce((prev, key) => {
        if (key in obj) {
            switch (key) {
                case Keys.Key1:
                    prev[key] = obj[key]?.value;
                    break;
                case Keys.Key2:
                    prev[key] = obj[key]?.value;
                    break;
            }
        }
        return prev;
    }, {} as B);
}

Is there way to write safe code through types without changing code? Because there can be complex types, like in B some property number, but in A number | null, and I want to see that I can't convert object A to object B. With any it won't work.


Solution

  • The problem you're running into is that TypeScript doesn't directly support correlated union types as described in microsoft/TypeScript#30581. It can't look at your reduce() callback and say that prev[key] = obj[key].value makes sense for an arbitrary key. The type of key is the union type Keys. TypeScript is observing the type of key and not its identity, so it treats your code as if you wrote prev[key1] = obj[key2].value where key1 and key2 are both of type Keys. That is clearly not safe (because key1 === key2 isn't guaranteed). The fact that this can't happen escapes the compiler, because in order to understand it, it would need to understand some higher order type relationship of the form "no matter what key is, the type of prev[key1] and the type of obj[key2].value will be the same". It can't make such observations by itself; you need to guide it.

    The supported approach for this sort of thing is to refactor your types as described in microsoft/TypeScript#47109. Instead of directly using a union type, you use generics. So key will be of a generic type constrained to Keys, and not Keys itself. All your operations need to be in terms of a "base" object type, and in terms of generic indexes into that base type, and in terms of generic indexes into mapped types over that type.

    Your B type is already the relevant base type, since it's a straightforward mapping from each key in Keys to the simple key-dependent part of what's going on:

    type B = {
        [Keys.Key1]?: string;
        [Keys.Key2]?: number;
    };
    

    Your A type needs to be refactored to be a mapped type over B. Otherwise there's no guarantee that A is related to B in a general way. So the new version of A could be:

    type A = {
        [K in keyof B]: { value: Exclude<B[K], undefined> }
    }
    

    And then, inside your reduce() callback, you need to make key a generic type, K extends Keys, instead of just Keys. Meaning the whole callback is now generic:

    function func(obj: A): B {
        return Object.values(Keys).reduce(<K extends Keys>
            (prev: B, key: K) => {
            if (key in obj) {
                prev[key] = obj[key].value;
            }
            return prev;
        },
            {},
        );
    }
    

    Now it compiles. The compiler believes that obj[key].value is of type Exclude<B[K], undefined>, which is assignable to B[K], the type of prev[key].

    Playground link to code