How can I modify my TypeScript code to resolve the type error in the last row (console.log(cf0.ftype)), ensuring proper type checking and invocation of the dynamically selected function from the ‘config’ object based on the generic ‘Filter’ type?
type FilterValues = {ip: string, magic: number}
type config = {
[key in keyof FilterValues]: (data: FilterValues[key]) => string
}
const c: config = {
ip: (data: string) => data,
magic: (data: number) => data.toString()
}
type Filter<T extends keyof FilterValues> = {
ftype: T;
value: FilterValues[T]
}
const f: Filter<keyof FilterValues>[] = [{
ftype: "magic",
value: 6
}, {
ftype: "ip",
value: "1.2.3.4"
}]
const f0 = f[0]
console.log(c["ip"]("hello"))
console.log(c["magic"](5))
console.log(c[f0.ftype](f0.value))
Playground Link: Provided
In order for the compiler to validate config[filter.ftype](filter.value) as safe, it needs to see config[filter.ftype] as having a single function type, and to see filter.value as having a type appropriate for that function.
The first problem is that your Filter<keyof FilterTypes> type is not correct. If you evaluate it, you get { ftype: "ip" | "magic", value: string | number }, meaning that something like { ftype: "ip", value: 123 } is possible, and that would of course be doing the wrong thing. Imagine c were actually
const c: Config = {
ip: (data: string) => data.toUpperCase(),
magic: (data: number) => data.toFixed()
}
and you'd see that there would be a runtime error. So the compiler error is warning you of an actually valid concern. Still, you could fix this by redefining Filter so that a union input becomes a union output. You want to distribute your Filter definition across unions. There are a few ways to do this, but the one that will work best is known as a distributive object type, as coined in microsoft/TypeScript#47109. It looks like this:
type Filter<K extends keyof FilterValues> = { [P in K]: {
ftype: P;
value: FilterValues[P]
} }[K]
We make Filter<K> a [mapped type](mapped types) over K into which we immediately index with K, distributing the inner object type over unions in K. You can test now that Filter<keyof FilterValues> evaluates to the union { ftype: "ip", value: string } | { ftype: "magic", value: number }. This correctly prevents someone from crossing their wires with { ftype: "ip", value: 123 }.
Unfortunately if you make this change, you'll still get the exact same error.
Your code is already part of the way there. FilterTypes is the basic interface, and Config is the appropriate mapped type over that interface. Your Filter type is a little inaccurate, since Filter<keyof FilterTypes> would actually be the unsafe { ftype: "ip" | "magic", value: string | number }, when you really want `{ ftype:
Again, to validate config[filter.ftype](filter.value), config[filter.ftype] needs to have a function type and filter.value needs to have the corresponding argument type. But now that filter is of a union type, the compiler sees config[filter.ftype] as being a union of functions, and filter.value is a union of arguments, and generally it's unsafe to call the former with the latter.
The compiler doesn't track the identity of filter here, just its type. And so the analysis proceeds as if you had written ``configfilter1.ftypewherefilter1andfilter2might be different members of the union. That is, the compiler loses the *correlation* between the two utterances offilterin the expression, even though we went out of our way to establish that correlation by fixingFilter`'s type.
This lack of support for correlated unions is the subject of microsoft/TypeScript#30581. If you don't want to just use type assertions to silence the warning, but instead want to get the compiler to actually verify what you're doing as safe, you can follow the steps laid out in microsoft/TypeScript#47109, which I referenced before.
That issue describes a refactoring away from unions toward generics, where everything is written in terms of some "basic" interface, and generic indexes into that basic interface or into mapped types over that interface.
Your code is already most of the way there. FilterTypes is the basic interface, and Config is the appropriate mapped type over that interface. And we have fixed Filter to be an indexed access into a mapped type over the basic interface. The only thing left to do is to write the function call where filter.ftype's type is a generic type constrained to a union, instead of a plain union.
You can't just declare a generic type parameter inline and use it. You need to wrap your code in a generic function instead. So, let's do that:
function apply<K extends keyof FilterValues>(config: Config, filter: Filter<K>) {
return config[filter.ftype](filter.value); // okay
}
That compiles just fine. If you look, filter.ftype is of type K, and so config[filter.ftype] is of type Config[K] which evaluates to (data: FilterValues[K]) => string. And since filter.value is of type FilterValues[K], everything works. And the return type of apply() is string.
Of course we need to call apply(), so that looks like
apply(c, f[0]);
and works as expected.
Such refactoring might not be worth it to you, but this is currently the recommended approach to maintaining some semblance of type safety in the face of correlated union types.
Playground 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