Search code examples
typescripttypescript-generics

Invert keyOf T type


Using typescript

how can i make "value" parameter in "formatValue" function to be the type of keyOf T?

export interface ICol<T extends Record<string, any>, K extends keyof T = keyof T> {
    accessor?: K;
    formatValue?: (value: T[K]) => JSX.Element | string | number;
}
const col: ICol<{ a: number; b: string; c: Date; }> = {
    accessor: 'a',
    formatValue: (value) => {
        return `Value is ${value}`;
    },
};

The tooltip must say value:number when accessor is "a" but value:string when accessor is "b". The current tooltip say: (parameter) value: string | number | date

enter image description here

I need value to be number in this case.

Thanks.


Solution

  • You want ICol<{ a: number; b: string; c: Date; }> without a second generic type argument K to be equivalent to the union of ICol<{ a: number; b: string; c: Date; }, K> for each K in keyof T. That is, you want to distribute a union in K over the ICol<T> operation. There are two relatively straightforward ways to do that.


    One is to use a distributive conditional type like this:

    type ICol<T extends Record<string, any>, K extends keyof T = keyof T> =
      K extends unknown ? {
        accessor?: K;
        formatValue?: (value: T[K]) => JSX.Element | string | number;
      } : never
    

    Because K is a generic type parameter, the conditional type K extends unknown ? ⋯ : never automatically distributes the over unions in K. We're not really trying to check anything (K extends unknown is always true), just use distributive conditional type. That results in:

    type Z = ICol<{ a: number; b: string; c: Date; }>;
    /* type Z = {
        accessor?: "a" | undefined;
        formatValue?: ((value: number) => JSX.Element | string | number) | undefined;
    } | {
        accessor?: "b" | undefined;
        formatValue?: ((value: string) => JSX.Element | string | number) | undefined;
    } | {
        accessor?: "c" | undefined;
        formatValue?: ((value: Date) => JSX.Element | string | number) | undefined;
    } */
    

    And then you get the behavior you expected where value is inferred as number:

    const col: ICol<{ a: number; b: string; c: Date; }> = {
      accessor: 'a',
      formatValue: (value) => {
        return `Value is ${value}`;
      },
    };
    

    The other is to use a distributive object type as coined in microsoft/TypeScript#47109, where you make a mapped type over the union members of K and then immediately index into that mapped type with K:

    type ICol<T extends Record<string, any>, K extends keyof T = keyof T> = { [P in K]: {
      accessor?: P;
      formatValue?: (value: T[P]) => JSX.Element | string | number;
    } }[K]
    

    Here we declare a type parameter P to iterate over the elements of K. This produces a mapped type with the same keys as T but whose properties are the object types we're looking for. When we index into this mapped type with K, we get the union of those object types.

    So the same output happens:

    type Z = ICol<{ a: number; b: string; c: Date; }>;
    /* type Z = {
        accessor?: "a" | undefined;
        formatValue?: ((value: number) => JSX.Element | string | number) | undefined;
    } | {
        accessor?: "b" | undefined;
        formatValue?: ((value: string) => JSX.Element | string | number) | undefined;
    } | {
        accessor?: "c" | undefined;
        formatValue?: ((value: Date) => JSX.Element | string | number) | undefined;
    } */
    

    These types still let you pass in a type for K if you don't want to use the default keyof T, so you can get a more specific type if you want:

    type X = ICol<{ a: number; b: string; c: Date; }, "a">;
    /* type X = {
      accessor?: "a" | undefined;
      formatValue?: ((value: number) => JSX.Element | string | number) | undefined;
    } */
    

    If you don't need that ability you can rewrite ICol<T> to remove the second type parameter:

    type ICol<T extends Record<string, any>> =
      keyof T extends infer K ? K extends keyof T ? {
        accessor?: K;
        formatValue?: (value: T[K]) => JSX.Element | string | number;
      } : never : never;
    

    or

    type ICol<T extends Record<string, any>> = { [P in keyof T]: {
      accessor?: P;
      formatValue?: (value: T[P]) => JSX.Element | string | number;
    } }[keyof T]
    

    Note that since ICol<T> needs to at least sometimes be a union type, you cannot make ICol<T> an interface. Interfaces can only represent single object types (or intersections of object types) with statically-known members.

    Playground link to code