typescriptgenerics

Using generic types with discriminated unions


I'm building a configurable Filter UI component using React + TypeScript.

export enum FilterType {
  CHECKBOX_LIST,
  DATE_RANGE,
  NUMBER_RANGE,
  COUNTRY_SELECT
}
export interface FilterConfigBase {
  id: string;
  label: string;
  validate?: () => Promise<boolean>;
}

// CONCERN HERE!!!
export interface CheckboxFilterConfig<T extends string | number>
  extends FilterConfigBase {
  type: FilterType.CHECKBOX_LIST;
  componentProps: CheckBoxListProps<T>;
}

export interface DateFilterConfig extends FilterConfigBase {
  type: FilterType.DATE_RANGE;
  componentProps: CalendarTabProps;
}

export interface RangeFilterConfig extends FilterConfigBase {
  type: FilterType.NUMBER_RANGE;
  componentProps: RangeFilterProps;
}

// ...Omitted for simplicity...


export type FilterConfig<CheckboxT extends string | number = string | number> =
  | CheckboxFilterConfig<CheckboxT>
  | DateFilterConfig
  | RangeFilterConfig;

// CheckBoxListProps
export interface CheckBoxListProps<T extends string | number> {
  list: { value: T; label: string }[];
  handleClick: (value: T) => void;
  selectedBoxes: T[];
}

//
export interface CalendarTabProps {
  isCalendarActive: boolean;
  selectedDateRange: [Date, Date];
  setDate: (dateRange: [Date, Date]) => void;
  setIsCalendarActive: (state: boolean) => void;
  maxAllowedDate?: Date;
}

export interface RangeFilterProps {
  errors: { from: string | null; to: string | null };
  handleChange: (value: [number, number] | null) => void;
  max: number;
  min: number;
  value: [number, number] | null;
}

enum Category {
  Foo = 'foo',
  Bar = 'bar'
}

const setNumberFilter = (value: number) => { }

const setStringFilter = (value: string) => { }

const setCategoryFilter = (value: Category) => { }

const config: FilterConfig[] = [
  {
    id: "filter1",
    label: "Filter 1",
    type: FilterType.CHECKBOX_LIST,
    componentProps: {
      // Issue with type here
      handleClick: (value) => { setNumberFilter(value) },
      list: [{ value: 1, label: "Foo" }, { value: 2, label: "Bar" }],
      selectedBoxes: [1],
    }
  },
  {
    id: "filter2",
    label: "Filter 2",
    type: FilterType.CHECKBOX_LIST,
    componentProps: {
      // Issue with type here
      handleClick: (value) => { setStringFilter(value) },
      list: [{ value: "foo", label: "Foo" }, { value: "bar", label: "Bar" }],
      selectedBoxes: ["foo"],
    }
  },
  {
    id: "filter3",
    label: "Filter 3",
    type: FilterType.CHECKBOX_LIST,
    componentProps: {
      // Issue with type here
      handleClick: (value) => { setCategoryFilter(value) },
      list: [{ value: Category.Foo, label: "Foo" }, { value: Category.Bar, label: "Bar" }],
      selectedBoxes: [Category.Foo],
    }
  }
]

This is the type definition. I plan on passing enums (and string/number) to CheckBoxListProps. When typing the config object like this:

const config: FilterConfig[] = [
{
  id: "filter1",
  label: 'date',
  type: FilterType.DATE_RANGE,
  componentProps: {
    isCalendarActive,
    selectedDateRange: filters.createdDateRange,
    setDate: (range: CalendarState) => {
        ...
    }
    setIsCalendarActive,
    maxAllowedDate: new Date()
  }
},
{
  id: 'filter2',
  label: 'rating',
  type: FilterType.CHECKBOX_LIST,
  componentProps: {
    handleClick: (rating) => {
      ...
    },
    list: ratingOptions,
    selectedBoxes: filters.rating
  }
}
]

The issue is that I have to pass in the type argument every time I use FilterConfig. And also I am not able to type the config object as required to make it reusable. For instance I have a FilterContainer component that takes in the config object and renders the UI. Since this FilterConfig itself is generic and I use it to type the config prop of FilterContainer, it expects a type argument as well.

The end goal is to use FilterConfig to type the prop of a React component. For the sake of simplicity, how can I achieve something like this-

const processFilter = (config: FilterConfig[]) => {
    config.map(console.log)
}

After trying a lot with this approach I'm pretty sure this is not the right way. If it is, what are the changes required? If not, what is the right way?


Solution

  • The problem is that there is no direct way to say "an array of FilterConfig<T> for some T I don't care about, where T could be different on every element for all I care". That would require existentially quantified generics, as requested in microsoft/TypeScript#14466 something like Array<<∃T>FilterConfig<T>>.TypeScript doesn't directly support those. Neither do most languages with generics, so it's not a particular failing of TypeScript.

    You can't use a default type argument to get that effect. An Array<FilterConfig> is just a Array<FilterConfig<unknown>>, and that unknown doesn't give you the correlation between the list, handleClick and selectedBoxes property types that you care about.

    There are ways to encode existentially quantified generics involving a Promise-like inversion of control. Briefly it would look like this:

    type SomeFilterConfig = 
      <R>(cb: <T>(cfg: FilterConfig<T>) => R) => R;
    

    A SomeFilterConfig is sort of like a Promise<FilterConfig<T>> for some T you don't know. It's effectively "wrapped" in a box that hides T from being seen outside. You pass it a callback, which receives a FilterConfig<T> for some T you don't know, and you can only do anything with it that makes sense for a generic FilterConfig<T>, and then whatever you return from the callback gets returned from SomeFilterConfig.

    Here's an example of how you might process an array of SomeFilterConfig:

    const processSomeFilterConfigs = (someConfigs: SomeFilterConfig[]) => {
      someConfigs.forEach(someConfg => someConfg(config => {
        if (config.type === FilterType.CHECKBOX_LIST) {
          config.componentProps.selectedBoxes.forEach(
            box => config.componentProps.handleClick(box)
          )
        }
      }))
    }
    

    There's that extra callback in there. In the inner callback where config is in scope, you can see that it's of type FilterConfig<T> for some T you don't know. You are allowed to take each element of config.componentProps.selectedBoxes and pass it to config.componentProps.handleClick because TypeScript knows those are the same T.

    And you can turn a FilterConfig<T> into a SomeFilterConfig for any T you do know, like this:

    const someFilterConfig = 
      <T,>(cfg: FilterConfig<T>): SomeFilterConfig => cb => cb(cfg);
    

    Then your code looks like this:

    const someConfigs: SomeFilterConfig[] = [
      someFilterConfig({
        id: "filter1",
        label: "Filter 1",
        type: FilterType.CHECKBOX_LIST,
        componentProps: {
          handleClick: (value) => { setNumberFilter(value) },
          list: [
            { value: 1, label: "Foo" },
            { value: 2, label: "Bar" }],
          selectedBoxes: [1],
        }
      }),
      someFilterConfig({
        id: "filter2",
        label: "Filter 2",
        type: FilterType.CHECKBOX_LIST,
        componentProps: {
          handleClick: (value) => { setStringFilter(value) },
          list: [
            { value: "foo", label: "Foo" },
            { value: "bar", label: "Bar" }],
          selectedBoxes: ["foo"],
        }
      }),
      someFilterConfig({
        id: "filter3",
        label: "Filter 3",
        type: FilterType.CHECKBOX_LIST,
        componentProps: {
          handleClick: (value) => { setCategoryFilter(value) },
          list: [
            { value: Category.Foo, label: "Foo" },
            { value: Category.Bar, label: "Bar" }],
          selectedBoxes: [Category.Foo],
        }
      })
    ];
    
    processSomeFilterConfigs(someConfigs); // okay
    

    Inside your array you are calling someFilterConfig(), which causes TS to infer the T properly (so all the setXXXFilter(value) calls work), and so you have a SomeFilterConfig[] and you're able to proceed.

    So that's the general purpose solution to situations like this. But it might be overkill.


    Instead of completely hiding the generic, you could just embrace that each element of your array has a different T, and ask TypeScript to keep track of those. You want TypeScript to infer each T for you. Unfortunately TypeScript infer type arguments for generic types. You can't write FilterConfig<infer> or anything, at least not without microsoft/TypeScript#32794. Again, default type arguments do not do this for you. TypeScript only infers type arguments when you call a generic function. So we still need to write a little helper function. Instead of someFilterConfig() which uses callbacks to hide the generic type argument, we'll write filterConfig() which just returns its input, hiding nothing:

    const filterConfig = <T,>(fc: FilterConfig<T>) => fc;
    

    Now you can write your array literal, like this:

    const config = [
      filterConfig({
        id: "filter1",
        label: "Filter 1",
        type: FilterType.CHECKBOX_LIST,
        componentProps: {
          handleClick: (value) => { setNumberFilter(value) },
          list: [{ value: 1, label: "Foo" }, { value: 2, label: "Bar" }],
          selectedBoxes: [1],
        }
      }),
      filterConfig({
        id: "filter2",
        label: "Filter 2",
        type: FilterType.CHECKBOX_LIST,
        componentProps: {
          handleClick: (value) => { setStringFilter(value) },
          list: [{ value: "foo", label: "Foo" }, { value: "bar", label: "Bar" }],
          selectedBoxes: ["foo"],
        }
      }),
      filterConfig({
        id: "filter3",
        label: "Filter 3",
        type: FilterType.CHECKBOX_LIST,
        componentProps: {
          handleClick: (value) => { setCategoryFilter(value) },
          list: [{ value: Category.Foo, label: "Foo" }, { value: Category.Bar, label: "Bar" }],
          selectedBoxes: [Category.Foo],
        }
      })
    ] as const
    

    This is similar to someConfig above, but now you're getting an array of type const config: readonly [FilterConfig<number>, FilterConfig<string>, FilterConfig<Category>]. That const assertion gives you a readonly tuple for config, where the first element is a FilterConfig<number>, the second is a FilterConfig<string>, and the third is a FilterConfig<Category>. All three type arguments have been inferred and preserved.

    So then, how do you process an array, if you can't say "an array of FilterConfig<T> but I really don't care what T is"? How do you write processFilterConfig()? Well, an existential generic for a function implementer looks like a regular generic for a function caller. That means you just need to make it generic as well:

    const processFilterConfig =
      <T extends readonly any[]>(configs: { [I in keyof T]: FilterConfig<T[I]> }) => {
        configs.forEach(<T,>(config: FilterConfig<T>) => {
          if (config.type === FilterType.CHECKBOX_LIST) {
            config.componentProps.selectedBoxes.forEach(
              box => config.componentProps.handleClick(box)
            )
          }
        })
      }
    
      processFilterConfig(config); // okay
    

    Note that the function is generic in the tuple of type arguments, like [number, string, Category], and the configs parameter is a mapped tuple type where each element of T is put into FilterConfig. So processFilterConfig() will accept an array of FilterConfig<???>.

    The body of processFilterConfig is the analogous code to processSomeFilterConfigs above, with a layer of callback removed. It's a little less type safe (TypeScript mostly thinks configs is of type FilterConfig<any>[] inside the function, because it can't keep track of generic mapped tuple types) but it's quite similar.


    For some use cases a full encoding of existential generics is useful, whereas in others it's mostly just extra work. Either way, though, you have to manipulate generics in a bit more complex ways than you might like, in order to work around some missing TypeScript features.

    Playground link to code