2

I'm creating a simple schema-like type to dynamically generate tables using React.

Those are the types that I wrote:

type MySchemaField<T, K extends keyof T> = {
    key: K;
    label: React.ReactNode;
    render?: React.ComponentType<{item: T, value: T[K]}>;
};
type MySchema<T> = {
    fields: MySchemaField<T, keyof T>[]; // <-- here is the problem: I don't want to specify the parameter K here because it depends on the single field
};

And this is how I'm expecting to use the schema:

type MyModel = {
  name: string;
  slug: string;
  location: {address: string, country: string};
};

const schema: MySchema<MyModel> = {
    fields: [
        {key: 'name', label: 'Name'},
        {key: 'slug', label: 'Link', render: ({value}) => value &&
            <a href={`/my-path/${value}`} target="_blank" rel="noreferrer">{value}</a> || null}, // value should be inferred as string
        {key: 'location', label: 'Address', render: ({value}) => <>{value.country}</>, // value should be {address, country}
    ],
};

I then have some React components accepting the values T and the schema MySchema<T>.

The underlying (React) code works just fine but I cannot find a way to correctly have value as a field of the type T[key]. Is there a way to do what I want or should I model differently the schema?

SecretAgentMan
  • 2,856
  • 7
  • 21
  • 41
aghidini
  • 2,855
  • 5
  • 29
  • 32

2 Answers2

2

You want the fields property of MySchema<T> to be a union of MySchemaField<T, K> for every K in keyof T. That is, you want to distribute MySchemaField<T, K> across unions in K.

There are different ways to do this. My approach here would probably be to make a distributive object type (as coined in microsoft/TypeScript#47109), where you make a mapped type and immediately index into it. Something like {[K in KS]: F<K>}[KS] will end up being a union of F<K> for every K in KS. In your case KS is keyof T, and F<K> is MySchemaField<T, K>. Like this:

type MySchema<T> = {
    fields: Array<{ [K in keyof T]-?: MySchemaField<T, K> }[keyof T]>;
};

Let's see how that works:

type MySchemaMyModel = MySchema<MyModel>
/* type MySchemaMyModel = {
    fields: (
      MySchemaField<MyModel, "name"> | 
      MySchemaField<MyModel, "slug"> | 
      MySchemaField<MyModel, "location">
    )[];
} */

That's what you want, right? And now everything is inferred as you desire:

const schema: MySchema<MyModel> = {
    fields: [
        { key: 'name', label: 'Name' },
        {
            key: 'slug', label: 'Link', render: ({ value }) => value &&
            <a href={`/my-path/${value}`} target="_blank" rel="noreferrer">{value}</a> 
            || null
        },
        { key: 'location', label: 'Address', render: ({ value }) => 
          <>{value.country}</>, }
    ],
};

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Yes, that's it, it works flawlessly. Thanks a lot for the links and the explanation, now I have something to study upon, the distribution part was exactly what I was missing. – aghidini Mar 14 '22 at 15:36
0

We need a way for TypeScript to associate (know that a key is associated with the value's type) in some way. Let's turn the union of keys into a tuple type, mimicking Object.keys.

How to transform union type to tuple type

type CreateFields<T, Keys, R extends ReadonlyArray<unknown> = []> =
    Keys extends [] // if no keys
        ? R         // then we are done
        : Keys extends [infer Key, ...infer Rest]
            ? CreateFields<T, Rest, [...R, {
                key: Key;
                label: React.ReactNode;
                render?: Key extends keyof T ? React.ComponentType<{ item: T; value: T[Key] }> : never;
            }]>
            : never;

We have the schema T, the supposed keys of T, Keys, and the result R. Loop through the keys, creating a field for each, with the correct value type for the field. This is basically mapping over the keys and "returning" a value.

Because this type returns a tuple, we can't use it directly. We have to get the type of the elements of the tuple first, which we can do with [number], and then make an array from that with [], resulting in this peculiar code:

type MySchema<T> = {
    fields: CreateFields<T, TuplifyUnion<keyof T>>[number][];
};

And this seems to work in the playground

Playground

p.s. there is definitely a better and more efficient way; this is just the first I came up with

kelsny
  • 23,009
  • 3
  • 19
  • 48
  • Whaaaaat are you doing here? Union-to-tuple is a crazy operation that is so full of caveats that the only reason to do it is to demonstrate how it goes wrong. This is like opening an aluminum can with a hand grenade. It's not just that there are better and more efficient ways to do it... it's hard to imagine worse ways. Please, for all that is good and holy, don't tell people to do this! – jcalz Mar 14 '22 at 15:23
  • Interesting, never heard of this. I tried it and the part of the declaration now works as expected, however I cannot use `fields` values anymore in the React components: `const MyTable = ({items, schema}: Props) => { schema.fields.map((field) => ( // here fields has no properties ` – aghidini Mar 14 '22 at 15:25