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?
CodePudding user response:
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}</>, }
],
};
CodePudding user response:
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
.
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
p.s. there is definitely a better and more efficient way; this is just the first I came up with