typescriptgeneric-type-parameters

Get the type of an item in "keyof" using a property and generic types


I want to define an interface with generic type that have to accept an object having its keys as "root fields name" and the value as an array of objects that defines some sub-fields having the key as the name of the sub-field and the type as the type of the field value. Something like this:

interface Inputs{
    emails: { email: string, active: boolean, i: number }[]
}

const obj:Inputs = {emails: [ {email: "...", active: true, i: 100} ]}

The interface who receive this as a generic type have a "name" property that will receive the (keyof) name of the sub-field ( ex. active ) and a function with a parameter that have to receive the type of the sub-field defined in the name property.

Something like this

    [
      {
        name: "active",
        component: ({ value, values }) => {
          console.log(value, values);
          return <>Component</>;
        }
      }
    ]

In this example, value must have "boolean" as only accepted type since the active key in the object has a boolean type.

I managed to get almost everything I wanted to do. The only problem is that instead of receiving the exact type of the subfield, the parameter of the function gets a union of all types of the object.

So in the previous example, since "email" is a string type, value should be string type, instead the receiving type is string | number | boolean ( all the available types in the object ).

I don't know if I have been able to explain myself well, but I have prepared a sandbox to do it better

https://codesandbox.io/s/boring-fast-pmmhxx?file=/src/App.tsx

interface Options<
  T extends { [key: string]: unknown }[],
  Key extends keyof T[number]
> {
  values: T;
  value: Key;
}

interface InputDef<
  T extends { [key: string]: any }[],
  Key extends keyof T[number]
> {
  name: Key;
  component: (props: Options<T, T[number][Key]>) => React.ReactNode;
}

interface Props<T extends { [key: string]: [] }, Key extends keyof T> {
  name: Key;
  inputs: InputDef<T[Key], keyof T[Key][number]>[];
  callback: (values: T) => void;
}

interface Inputs {
  firstName: string;
  lastName: string;
  emails: { email: string; active: boolean; other: number }[];
}

const GenComponent = <T extends { [key: string]: any }, Key extends keyof T>({
  name,
  inputs
}: Props<T, Key>) => {
  console.log(inputs);
  return (
    <div>
      {name} {JSON.stringify(inputs)}
    </div>
  );
};

interface MainComponentProps {
  callback: TestCallback<Inputs>;
}

const MainComponent: React.FC<MainComponentProps> = ({ callback }) => {
  return (
    <>
      <GenComponent
        callback={callback}
        name="emails"
        inputs={[
          {
            name: "active",
            component: ({ value, values }) => {
              console.log(value, values);
              return <>Component</>;
            }
          }
        ]}
      />
    </>
  );
};

type TestCallback<Data> = (values: Data) => void;

function test<Data>(values: Data): void {
  console.log(values);
}

export default function App() {
  return (
    <div className="App">
      <MainComponent callback={test} />
    </div>
  );
}

On line 57, since the name in the object is "active" the type of value should be "boolean" instead of "string | number | boolean". How can I achieve this?

Thanks!


Solution

  • I'm going to simplify your example to show where the problem is and how to fix it. First, you have a generic KeyValFunc<T, K> type that takes an object type T and one if its key types K, and hold both that key and a function that accepts a value whose type matches the object type's property for that key:

    interface KeyValFunc<T, K extends keyof T> {
        key: K,
        valFunc: (val: T[K]) => void
    }
    

    So for this interface

    interface Foo {
        x: number,
        y: string,
        z: boolean
    }
    

    You can write a value of type KeyValFunc<Foo, "x"> whose key is of type "x" and whose valFunc is of type (val: number) => void:

    const obj: KeyValFunc<Foo, "x"> = { key: "x", valFunc: val => val.toFixed() }; // okay
    

    That's all well and good, but now you want an array of KeyValFunc<T, K> for some given T but where you don't care what the specific K is, and in fact K can be different for each array element. That is, you want a heterogenous array type. Your idea is to write that as KeyValFunc<T, keyof T>[]:

    type KeyValFuncArray<T> = KeyValFunc<T, keyof T>[];
    

    But, unfortunately, this doesn't work:

    const arr: KeyValFuncArray<Foo> = [
        { key: "x", valFunc: val => val.toFixed() } // error!
        // ---------------------------> ~~~~~~~
        // Property 'toFixed' does not exist on type 'string | number | boolean'.
    ]
    

    Why doesn't the compiler realize that the x key goes with the number value? Why is val typed as string | number | boolean?


    The issue is that KeyValFunc<T, keyof T> is not the element type you want it to be. Let's examine it for Foo:

    type Test = KeyValFunc<Foo, keyof Foo>;
    /* type Test = KeyValFunc<Foo, keyof Foo> */;
    

    Oh, that's not very informative. Let's define an identity mapped type so we can use it on KeyValFunc to see each property.

    type Id<T> = { [K in keyof T]: T[K] };
    
    type Test = Id<KeyValFunc<Foo, keyof Foo>>;
    /* type Test = {
         key: keyof Foo;
         valFunc: (val: string | number | boolean) => void;
    } */
    

    So that's the problem. By using keyof T for K, each array element can have any property key of Foo (keyof Foo), and the parameter to valFunc can be any property value of Foo (Foo[keyof Foo]). That's not what we want.


    Instead of plugging keyof T in as K in KeyValFunc<T, K>, what we really want to do is distribute KeyValFunc<T, K> across unions in K. That is, for each K in the keyof T union, we want to evaluate KeyValFunc<T, K>, and then combine them together in a new union.

    Here's one way to write that:

    type SomeKeyValueFunc<T> = { [K in keyof T]-?: KeyValFunc<T, K> }[keyof T]
    

    This is a mapped type which is immediately indexed into with all its keys, to produce the union property we want. Let's verify that it does what we want for Foo:

    type Test = SomeKeyValueFunc<Foo>;
    //type Test = KeyValFunc<Foo, "x"> | KeyValFunc<Foo, "y"> | KeyValFunc<Foo, "z">
    

    Yes, that's better. Now Test is itself a union of three types, each of which is KeyValFunc for a particular key of Foo. And so for KeyValFuncArray<T>, we want to use SomeKeyValueFunc<T> instead of KeyValueFunc<T, keyof T>:

    type KeyValFuncArray<T> = SomeKeyValueFunc<T>[];
    

    And suddenly things work as expected:

    const arr: KeyValFuncArray<Foo> = [
        { key: "x", valFunc: val => val.toFixed() } // okay
    ]
    

    Playground link to code