Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Type 'string | number' is not assignable to type 'never'

I want to dynamically map the keys and values from list into obj. However, TS gives me an error message:

Type 'string | number' is not assignable to type 'never'

where I have no idea about what's wrong. Below is the code snippet:

interface T {
    // uncomment the next line makes the error go away
    // [k: string]: any
    a: string;
    b?: string;
    c?: number;
}

const obj: T = {
    a: 'something',
};

const list: Array<{
    foo: keyof T;
    bar: string | number;
}> = [
    { foo: 'b', bar: 'str' },
    { foo: 'c', bar: 1 },
];

list.forEach(item => {
    const { foo, bar } = item;

    // The error message comes from the next line
    obj[foo] = bar;
});

I notice that if I include the typing [k: string]: any into the interface T, the error message goes away.

However, I am reluctant to do that because I can then add other key/value pairs into the obj, such as obj.d = 'error' without TS warning me.

Also, I am curious about why TS would gives me this error message and what is the type never thing is all about.

For the tsconfig.json, I am using the default values by running tsc --init with version 3.5.1

Thank you.

like image 375
Ray Chan Avatar asked Dec 14 '19 19:12

Ray Chan


1 Answers

TypeScript 3.5 closed a loophole whereby index-access writes on unions of keys were not being properly checked. If I have an object obj of type T, and a key foo of generic type keyof T, then although you can safely read a property of type T[keyof T] from obj[foo],like const baz: T[keyof T] = obj[foo], it might not be safe to write such a property, like const bar: T[keyof T] = ...; obj[foo] = bar; In your code, foo might be "a" and bar might be 1, and that would be unsafe to write.

The way the loophole got closed: if I read a value from a union of keys, it becomes a union of the property types, as before. but if I write a value to a union of keys, it becomes an intersection of property types. So say I have an object o of type {a: string | number, b: number | boolean} and I want to write something to o[Math.random()<0.5 ? "a" : "b"]... what is safe to write? Only something which works for both o.a and o.b... that is, (string | number) & (number | boolean), which (when you fiddle with distributing unions across intersections and reducing) becomes just number. You can only safely write a number.

In your case, though, the intersection is string & string & number. And unfortunately, there's no value which is both a string and a number... so that gets reduced to never. Oops.


To fix this case I'd probably refactor this code so that list is more narrowly typed, only allowing "matching" foo and bar properties, and then pass the forEach method a generic callback where foo and bar are annotated so that obj[foo] and bar are seen as identical types:

type KV = { [K in keyof T]-?: { foo: K, bar: NonNullable<T[K]> } }[keyof T]
/* type KV = {
    foo: "a";
    bar: string;
} | {
    foo: "b";
    bar: string;
} | {
    foo: "c";
    bar: number;
} */

const list: Array<KV> = [
    { foo: 'b', bar: 'str' },
    { foo: 'c', bar: 1 },
];

list.forEach(<K extends keyof T>(item: { foo: K, bar: NonNullable<T[K]> }) => {
    const { foo, bar } = item;
    obj[foo] = bar; // okay
});

The KV type does a little type juggling with mapped and lookup types to produce a union of all acceptable foo/bar pairs, which you can verify by using IntelliSense on the KV definition.

And the forEach() callback acts on a value of type item: { foo: K, bar: NonNullable<T[K]> } for generic K extends keyof T. So obj[foo] will be seen as type T[K], and you'll assign a NonNullable<T[K]> to it, which is acceptable according to a rule that isn't quite sound but convenient enough to be allowed.

Does that make sense? Hope that helps; good luck!

Link to code

like image 126
jcalz Avatar answered Oct 26 '22 09:10

jcalz