I want to define a generic interface which is able to handle any Data
type. The interface has a dataKey
property and its value is simply a keyof Data
. It also has a handler function, and its parameter type should be the same type as the type of using dataKey
to read a value from Data
. Is should be something like this, but this does not work as Data[dataKey]
is not valid TypeScript:
interface Handler<Data> {
dataKey: keyof Data,
handler: (value: Data[dataKey]) => void
}
Is there a way to make it work? I could use any
type instead of Data[dataKey]
, but that doesn't make it type safe.
Here is an example how I would like to use the Handler
interface:
function handleData<Data extends object>(data: Data, handler: Handler<Data>) {
const value = data[handler.dataKey];
handler.handler(value);
}
interface Person {
name: string,
age: number,
}
const person: Person = {name: "Seppo", age: 56};
const handler: Handler<Person> = {dataKey: "name", handler: (value: string) => {
// Here we know that the type of `value` is string,
// as it is the type of reading `name` from the person object.
// If I change dataKey to "age", the type of `value`
// should be `number`, respectively
console.log("Name:", value);
}}
handleData(person, handler);
CodePudding user response:
For Handler<Person>
to evaluate to a type that enforces the correlation between the dataKey
property and the handler
callback parameter type, you pretty much need it to be a union type with one member for eavery property of Person
. It would need to look like this:
interface Person {
name: string,
age: number,
}
type PersonHandler = Handler<Person>;
/* type PersonHandler = {
dataKey: "name";
handler: (value: string) => void;
} | {
dataKey: "age";
handler: (value: number) => void;
} */
That way a Handler<Person>
would either be a value of type {dataKey: "name", handler: (value: string) => void
, or it would be a value of type {dataKey: "age", handler: (value: number) => void
.
So we are looking for a way to define type Handler<T extends object> = ...
so that it generates the appropriate union type. (Note that interface
s cannot be union types, so I've changed Handler
from an interface to a type alias.)
Here's one way to do it:
type Handler<T> = { [K in keyof T]-?: {
dataKey: K,
handler: (value: T[K]) => void
} }[keyof T]
This is what's known as a distributive object type as coined in microsoft/TypeScript#47109. A distributive object type is a mapped type which is immediately indexed into to get a union of its property types. You can verify that if you have a union of keys type K = K1 | K2 | K3 | ... | KN
, then the distributive object type {[P in K]: F<P>}[K]
evaluates to F<K1> | F<K2> | F<K3> | ... | F<KN>
.
In the above Handler<T>
definition, we iterate over the keys of T
(using the -?
mapping modifier to remove any possible optionality from the properties, to suppress unwanted undefined
types floating around in the output), and for each key K
, we compute the corresponding dataKey
/handler
pair. And then we index into it with the same set of keys, to get the union of all such values.
With that definition, Handler<Person>
is the desired type:
type PersonHandler = Handler<Person>;
/* type PersonHandler = {
dataKey: "name";
handler: (value: string) => void;
} | {
dataKey: "age";
handler: (value: number) => void;
} */
And then the assignment also works as desired:
const handler: Handler<Person> = {
dataKey: "name", handler: value => {
console.log("Name:", value.toUpperCase());
}
}
Note that you don't have to annotate that value
is a string
. The compiler is able to infer contextually that value
must be string
because Handler<Person>
is a discriminated union, and the discriminant dataKey
is "name"
.