Typescript - Narrowing T[K] in a function when multiple key values are passed in

Viewed 249

I'm attempting to write a hook that accepts multiple keys of an object and filters down the data based on currently applied filters. So, something along the lines of this:

interface IFilterable<T,K extends keyof T & string>{
  key: K;
  customFilter?: (item: T[K], applied: T[K][]) => boolean
  //The hook uses some default logic to filter if this function isn't available.
}

function useFilters<T,K extends keyof T & string>(data: T[], filters: IFilterable<T,K>[]): T[]{
 //filter out the data, return new dataset.
}

The issue is that when multiple filters (IFilterable) are passed in, the item and applied parameters in the customFilter function becomes the union of the types of the keys. Or, in code...

useFilters(
  [{a: 1, b: "bob"], {a: 2, b: "berry"}],
  [{key: "a", customFilter: (item, applied) => {
    //Wanted: item has the type number
    //Reality: item has the type (string | number)
  }, 
  {key: "b", customFilter: (item, applied) => {
    //Wanted: item has the type string.
    //Reality: item has the type (string | number)
  }
}]);

I have "solved" the problem by typing everything as unknown(or unknown[]) and then creating wrapper functions that cast things back to the proper type later, but I want to keep things strongly typed the whole way through if possible.

So, is there any way in cases like this, where I am passing in array of objects where one property is a key and another property is a function that takes T[K], to narrow to the type of a specific T[K] instead of the union type?

Thanks.

1 Answers

UPDATE 17 September 2021

I made my homework.

Consider this example:

/**
 * Obtain a union of all values of a dictionary
 */
type Values<T> = T[keyof T]

type FilterBuilder<T extends any[]> = Values<{
  [Key in keyof T[number]]: {
    key: Key,
    customFilter: (item: T[number][Key], applied: T[number][Key][]) => void
  }
}>

{
  //   type Test = {
  //     key: "a";
  //     customFilter: (item: 1, applied: 1[]) => void;
  // } | {
  //     key: "b";
  //     customFilter: (item: "str", applied: "str"[]) => void;
  // }
  type Test = FilterBuilder<[{ a: 1, b: 'str' }]>
}

const withFilters = <
  Item extends Record<PropertyKey, unknown>,
  Items extends Item[],
  >(data: [...Items]) => <
    Filter extends FilterBuilder<Items>,
    Filters extends Filter[],
    >(filters: [...Filters]) => { }

const useFilters = withFilters([{ a: 1, b: "bob" }, { a: 2, b: "berry" }])

const result = useFilters(
  [{
    key: "a", customFilter: (item, applied) => { }
  },
  {
    key: "b", customFilter: (item, applied) => { }
  }]
);

It is hard for typescript to infer the key of object and apply this type immediately to another property of same object, at least in this case.

Hence, I just created a union of all allowed key/customFilter filters pairs.

Playground

Values - creates a union of all values of the dictionary data structure

FilterBuilder:

  1. iterates over each key of elements which exists in the tuple T.
  2. creates a new object for iterated key with key and customFilter props.
  3. Where customFilter is a function with two arguments: item - value of iterated object, applied - array of values of iterated object

withFilters: is a curried function. It expects a tuple of objects which we are interested in and returns function which expects an array of FilterBuilder results. FilterBuilder just returns a discriminated union of all allowed states of key/customFilter object.

Related