Home > Mobile >  Defining an object type depending on field in another object
Defining an object type depending on field in another object

Time:10-08

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.

Playground link

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, ... }).

Playground

  • Related