Home > Net >  TypeScript: Typing generic interface's method parameter by reading a value from the generic typ
TypeScript: Typing generic interface's method parameter by reading a value from the generic typ

Time:11-29

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 interfaces 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".

Playground link to code

  • Related