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.
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With