Eugene Wolffe
Eugene Wolffe

Reputation: 155

Why is TypeScript Not Checking the Type of Dynamic Key Object Fields

Why does TypeScript accept the definition of seta when it does not return objects of type A?

type A = {
    a: '123',
    b: '456'
}

// Returns copy of obj with obj[k] = '933'
function seta<K extends keyof A>(k: K, obj: A): A {
    return {
        ...obj,
        [k]: '933'
    }
}

const w: A = seta('a', {
    a: '123',
    b: '456'
})

// now w = {a: '933', b: '456'}

https://tsplay.dev/wEGX4m

Upvotes: 8

Views: 1033

Answers (2)

jcalz
jcalz

Reputation: 328618

This looks like a bug or limitation in TypeScript; see microsoft/TypeScript#37103 (labeled a bug) or microsoft/TypeScript#32236 (labeled "needs investigation").

When you spread properties into an object literal and then add a computed property, it seems that TypeScript will completely ignore the computed property unless the computed key is of a single, specific, literal type (not a union of literals, and not a generic type parameter constrained to a string literal):

function testing<K extends "a" | "b">(a: "a", x: string, y: "a" | "b", z: K) {
    const someObject = { a: "v" } // { a: string }
    const objA = { ...someObject, [a]: 0 } // { a: number } 👍
    const objX = { ...someObject, [x]: 0 } // { a: string } 👎
    const objY = { ...someObject, [y]: 0 } // { a: string } 👎
    const objZ = { ...someObject, [z]: 0 } // { a: string } 👎
}

Computed property keys in general are a bit problematic in TypeScript, even without spreading, as they tend to get widened all the way to string, losing information you might care about (see another bug at microsoft/TypeScript#13948):

function testing2<K extends "a" | "b">(a: "a", x: string, y: "a" | "b", z: K) {
    const objA = { [a]: 0 } // { a: number } 👍
    const objX = { [x]: 0 } // { [x: string]: number; } 🤷‍♂️
    const objY = { [y]: 0 } // { [x: string]: number; } 🤷‍♂️
    const objZ = { [z]: 0 } // { [x: string]: number; } 🤷‍♂️
}

So that's the problem you're having here.


I don't know what to say other than "tread lightly around computed properties". You can kind of work around it by defining your own function that produces a more "correct" version of what a computed property would be:

function kv<K extends PropertyKey, V>(k: K, v: V) {
    return { [k]: v } as { [P in K]: { [Q in P]: V } }[K]
}

const k = Math.random() < 0.5 ? "a" : "b";

const obj = kv(k, Math.random());
/* const obj: {
    a: number;
} | {
    b: number;
} */

You can see that a union of keys produces a union of objects. But if the key is generic then the compiler leaves that unevaluated and spread with generics tends to be represented as an intersection, which is often acceptable but not when keys overlap. (See microsoft/TypeScript#32022 for that issue):

function setA2<K extends keyof A>(k: K, obj: A): A {
    return {
        ...obj,
        ...kv(k, '933')
    } 
    /* const ret1: A & { [P in K]: { [Q in P]: string; }; }[K] */ // no error
}

So you'd have to widen k from K to keyof A before you got any warning:

function setA3<K extends keyof A>(k: K, obj: A): A {
    const kW: keyof A = k;
    return {
        ...obj,
        ...kv(kW, '933')
    }; // FINALLY AN ERROR
    // Type '{ a: string; b: "456"; } | { b: string; a: "123"; }' is not assignable to type 'A'.
}

So, hooray, I guess... you can spend a lot of effort to wrestle some type safety out of this function, but it's quite a pyrrhic victory (I think that's a word). So again, be careful around computed properties until and unless the relevant GitHub issues are fixed.

Playground link to code

Upvotes: 3

Wolfgang
Wolfgang

Reputation: 1398

It's accepted because if you hover on [k]: '933', it says

(parameter) k: K extends keyof A

Which means you're returning a property that's extended from the return type, which is allowed.

When you declare that an object must be of an interface, you are saying that it must have at least those properties, not only those properties.

Upvotes: 1

Related Questions