I'm making an HTML table generator that takes a columns
array and a rows
array.
The goal is for the table generator to require certain types for each field in the objects of the rows
array, depending on the "type" property for the corresponding key name in the columns
object.
So for example, some example arguments for a "users" table where column 1 is the username and column 2 is a link to that user's profile:
TableBody({
columns: [
{ key: "username", label: "Username", type: "string" },
{ key: "userProfile", label: "Profile Page", type: "link" },
],
rows: [
{
// username must be a string, according to the columns array
username: "MangoMcgee",
// userProfile must be a "link" type, according to the columns array
userProfile: { text: "Mango's home page", href: "/user/mango-mcgee" },
},
{
username: "PineapplePete",
userProfile: {
text: "Pineapple's home page",
href: "/user/pineapple-pete",
},
},
],
});
I've defined my columns and rows like this:
type tableColumn<K> = {
key: K;
label: string;
type: "string" | "link";
};
type tableRow<T extends tableColumn<string>[]> = {
[key in T[number]["key"]]: "link" extends T[number]["type"]
? { text: string; href: string }
: string;
};
Where I'm stuck: my definition of tableRow<T>
is flawed.
My issue (I think) is that I was thinking T[number]["type"]
would either be "string"
or "link"
, and I could then use the conditional of "link" extends T[number]["type"]
to decide whether the property should be a string
or a { text: string; href: string }
.
But I've realized that since I've defined tableColumn<K>["type"]
to be the union "string" | "link"
, it's always just that. It's not actually "string"
or "link"
, it's the union itself of "string" | "link"
. So that conditional is always true.
I think I need to make the type
property on tableColumn
generic, instead of a union. But if tableColumn
takes another generic type parameter, I'm not sure where I would actually pass in the type argument.
CodePudding user response:
To get started, let's map some of the "types" to the types we'll be using for the result:
type ColTypes = {
string: string;
link: {
text: string;
href: string;
};
};
Then we'll create a type to make another type, given the columns:
type TypeFromCols<
Cols extends readonly { key: string; label: string; type: "string" | "link" }[]
> = {
[K in Cols[number]["key"]]: ColTypes[Extract<
Cols[number],
{ key: K }
>["type"]];
};
Mapping over each key, we extract the column from the array and then get the corresponding type. The definition for TableBody
now looks like:
type ColArgs = {
key: string;
label: string;
type: "string" | "link";
};
const TableBody = <Cols extends readonly ColArgs[]>(body: {
columns: Narrow<Cols>;
rows: readonly TypeFromCols<Cols>[];
}) => { /* ... */ };
You'll notice another type, Narrow
, and this is just to make TypeScript infer the argument as a giant object literal (if it was passed directly):
type Narrow<T> =
| (T extends infer U ? U : never)
| Extract<
T,
number | string | boolean | bigint | symbol | null | undefined | []
>
| ([T] extends [[]] ? [] : { [K in keyof T]: Narrow<T[K]> });
This has no effect on the result and is just a QoL thing so you don't need as const
when using TableBody({ columns: { ... } as const, ... })
.