Consider the following example:
type columns = {
A: number;
B: string;
C: boolean;
};
const mapper: { [T in keyof columns]: (item: columns[T]) => string } = {
A: item => `${item}`,
B: item => item,
C: item => (item ? "Good" : "Bad"),
};
const data: columns[] = [
{ A: 0, B: "Hello", C: true },
{ A: 1, B: "World", C: false },
];
const keys: (keyof columns)[] = ["A", "B", "C"];
data.map(item => keys.map(key => mapper[key](item[key] as never)));
// should return [["0", "Hello", "Good"], ["1", "World", "Bad"]]
In the last line, key
is of type keyof columns
, i.e. "A" | "B" | "C"
, which makes mapper[key]
reduce to (item: number & string & boolean) => string
, i.e. (item: never) => string
. (Point out if my concept is wrong.)
So the question is, how can I rewrite the code such that item[key]
does not need to be casted to never
?
CodePudding user response:
This is a general limitation with TypeScript and the subject of microsoft/TypeScript#30581. The compiler is really not able to look at a single expression like mapper[key](item[key])
and use control flow analysis to analyze it to see that it's safe.
The problem is that mapper[key]
and item[key]
are both of union types. The type of mapper[key]
is ((item: number) => string) | ((item: string) => string) | ((item: boolean)=>string)
and the type of item[key]
is number | string | boolean
. But the compiler has no good way to keep track of the correlation between the types of those values. It treats all unions as essentially independent of each other. We know that mapper[key]
is (item: number) => string
exactly when item[key]
is number
, but the compiler doesn't. For all it understands, mapper[key]
could accept a number
while item[key]
is string
. The correlation has to do with the fact that we're using the same key
in both expressions, but the compiler only tracks the type of key
, and not its identity.
When you treat mapper[key]
and item[key]
independently, you're kind of stuck. You can only call a union of function types with arguments that would work for every member of the union. That is, an intersection of the parameters... so the compiler sees mapper[key]
as assignable to (item: number & string & boolean) => string
, aka (item: never) => string
... meaning such a function is never safe to call in general.
Until and unless there is some way to tell the compiler to track correlations between expressions of union type, there's no great way to proceed. If you care about type safety above all else, you can write some redundant code to get it:
data.map(item => keys.map(key =>
key === "A" ? mapper[key](item[key]) :
key === "B" ? mapper[key](item[key]) :
mapper[key](item[key])
)); // no error but it's redundant and repetitive
// and also redundant
If you care about convenience instead of type safety, then you can use a type assertion to suppress errors. Your example of item[key] as never
is one way to do it, although you're technically lying about what item[key]
is. If you don't want to lie you can use a generic callback function like this:
data.map(item => keys.map(<K extends keyof Columns>(key: K) =>
(mapper[key] as (item: Columns[K]) => string)(item[key]) // okay
));
You have to assert that mapper[key]
is a value of type (item: Columns[K]) => string)
because the compiler can't verify that, even though it theoretically should be able to. It eagerly resolves mapper[key]
to a union of functions when you try to call it. And since mapper[key]
truly is a value of that type, we're not lying. The lack of type safety here comes from the fact that if someone evil switched around mapper
's entries, the compiler wouldn't notice:
const evilMapper = {
A: mapper.B,
B: mapper.C,
C: mapper.A
}
data.map(item => keys.map(<K extends keyof Columns>(key: K) =>
(evilMapper[key] as (item: Columns[K]) => string)(item[key]) // okay?!
));
whereas the redundantly redundant version would start screaming about it:
data.map(item => keys.map(key =>
key === "A" ? evilMapper[key](item[key]) : // error
key === "B" ? evilMapper[key](item[key]) : // error
evilMapper[key](item[key]) // error
));