Search code examples
typescriptmapped-typesconditional-types

TypeScript not recognizing mapped type with conditional types violation


I need help with these type definitions. TS is not showing me a warning but I believe I'm violating the type.

type ElementType = "ShortText" | "Status" | "Number";

type BoardType = {
  id: string;
  name: string;
  columns: Array<ColumnType>;
};

type Column = {
  id: string;
  name: string;
  element: ElementType;
};

type StatusType = Column & {
  options: { option: string; color: string }[];
};

type ColumnType = Column | StatusType;

type ColumnValue<T extends ElementType> = T extends "ShortText"
  ? string
  : T extends "Status"
  ? string
  : T extends "Number"
  ? number
  : never;

type PulseType<Columns extends ColumnType[]> = {
  id: string;
  board: string;
  data: {
    [K in Columns[number]["id"]]?: K extends Columns[number]["id"]
      ? ColumnValue<Extract<Columns[number], { id: K }>["element"]>
      : never;
  };
};

const board: BoardType = {
  id: "board-1",
  name: "Sample Board",
  columns: [
    { id: "Name", name: "Full Name", element: "ShortText" },
    {
      id: "Status",
      name: "To DOs",
      element: "Status",
      options: [
        { option: "To Do", color: "grey" },
        { option: "In Progress", color: "blue" },
        { option: "Done", color: "green" },
      ],
    },
    { id: "Age", name: "Age", element: "Number" },
  ],
};

const pulses: PulseType<typeof board.columns> = {
  id: "1",
  board: "board-1",
  data: {
    Name: "John Doe",
    Status: "Done",
    Age: 25,
    // Uncommented 'ehh', which is not a valid column ID
    ehh: "error", // Error: 'ehh' is not assignable to 'never'
  },
};

The goal is to have a board. In that board there's an array called columns. Based on the columns it should determine the type of a pulse. It's kind like a list and items.

In BoardType it takes an array of ColumnType, which is the union Column | Status | Relationship. The reason for this is basically, instead of keeping all the settings in the pulse, any common info is kept in the board. For example the status has options; those options are the same for each pulse in the board, so the options are kept in the column which is extended by Status.

The ElementType is just all the possible values of board.columns[].element.
ColumnValue is a conditional type that returns the type of each element

The issue I'm running into is, when I pass PulseType the argument, it doesn't enforce that the data object in it only has the keys of the ids in the board's column array.

As you see, the ehh property should cause TypeScript to give a warning, but it's not.


Solution

  • First of all, it won't be possible with the current board, since the compiler needs to know the exact shape of the board, which is impossible just by typing it as BoardType. Instead, we will need to use const assertion that will prevent the compiler from widening the types to their primitives. With const assertion there will no longer be type checking, which can be fixed by using the satisfies operator. Since const assertion will turn every array to their read-only versions, we will need to update all array/tuple properties to become read-only:

    type BoardType = {
      id: string;
      name: string;
      columns: readonly ColumnType[];
      groups?: readonly (readonly string[])[];
      access?: boolean;
      members?: readonly [{ id: string; access: readonly ['admin', 'viewer'] }];
      owner: string;
      apps?: readonly string[];
      created_at: string;
      updated_at: string;
    };
    
    const board = {
      id: 'board-1',
      name: 'Sample Board',
      columns: [
        { id: 'Name', name: 'Full Name', element: 'string.ShortText' },
        { id: 'Age', name: 'Age', element: 'number.Number' },
      ],
      groups: [['Admins'], ['Editors', 'Viewers']],
      access: true,
      members: [{ id: 'user-1', access: ['admin', 'viewer'] }],
      owner: 'user-1',
      apps: ['app-1', 'app-2'],
      created_at: '2023-07-01',
      updated_at: '2023-07-15',
    } as const satisfies BoardType;
    

    In the mapped type instead of mapping through Columns[number]["id"]] just map through the whole elements (Columns[number]) and you can change the key to the value of id property by using the key remapping. There is also no point in checking if K extends X where X is what you are mapping. Since not all columns have element property, we will check whether the current item has this property and if it does then we will pass it to ColumnValue, otherwise the value will be never:

    type PulseType<Columns extends readonly ColumnType[]> = {
      id: string;
      board: string;
      data: {
        [K in Columns[number] as K['id']]?: K extends {
          element: ElementType;
        }
          ? ColumnValue<K['element']>
          : never;
      };
      created_at: string;
      updated_at: string;
    };
    

    Testing:

    const pulses: PulseType<typeof board.columns> = {
      id: '1',
      board: 'board-1',
      data: {
        Name: 'John Doe',
        Age: +new Date(),
        ehh: 'error', // expected error
      },
      created_at: '2022-01-01',
      updated_at: '2022-01-01',
    };
    

    playground