Search code examples
typescriptunion-types

Argument of type 'string | number | boolean' is not assignable to parameter of type 'never'. ts2345


I'm using the following union type

interface TypeMap {
  boolean: boolean;
  number: number;
  string: string;
}

interface Item {
  id: string;
}

type FieldHeader<I extends Item> = {
  [Type in keyof TypeMap]: {
    compare: (a: TypeMap[Type], b: TypeMap[Type]) => number;
    filter: (value: TypeMap[Type], input: TypeMap[Type]) => boolean;
    get: (item: I) => TypeMap[Type] | null | undefined;
    type: Type;
  };
}[keyof TypeMap];

Setting

type TableHeader<I extends Item> = Record<
  string,
  FieldHeader<I>
>;

type Filter<I extends Item, T extends TableHeader<I>> = {
  [Field in keyof T]: TypeMap[T[Field]['type']];      // this isn't working
}

firstly, the type of properties of Filter is not deduced correctly. Given an Item and TableHeader definition I could define a Filter with any property key and any value without getting any errors, see here (l. 44)

And if I set

function applyFilter<I extends Item, T extends TableHeader<I>>(item: I, tableHeader: T, filter: Filter<I, T>) {
  Object.entries(filter).forEach(([key, input]) => {
    const value = tableHeader[key].get(item);

    if (value != null) tableHeader[key].filter(value, input);
  })
}

I'm getting the error

Argument of type 'string | number | boolean' is not assignable to parameter of type 'never'.
  Type 'string' is not assignable to type 'never'.(2345)

(see here, l. 56) which is not totally surprising, question is, how can I resolve this error?


Solution

  • TypeScript cannot track correlations across union types; if you've got a single code block like your forEach() callback body, the compiler can only analyze it once. If you have multiple expressions in such a code block that depend on the same union-typed value, the compiler will usually just treat these expressions as being of union types themselves, but treat them independently. So a function that depends on a union key will become a union of functions. And an argument that depends on a union key will become a union of arguments. But you can't call a union of functions with a union of arguments safely; the compiler will insist on an intersection of arguments. This is the subject of microsoft/TypeScript#30581.

    The easiest approach here is to just use type assertions liberally to quiet the errors because you're sure you're doing it right.

    If you want the compiler to type check what you're doing, though, you can do a fairly large refactoring to use generic indexes into mapped types as described in microsoft/TypeScript#47109.

    This code has already gone through one round of such refactoring, but now you need to do it for the mapping type T from table header keys to the particular field type. We have to keep FieldHeader generic in K extends keyof TypeMap to do it; it's fine for FieldHeader<I> to default to a union, but we want to be able to pick particular K:

    type FieldHeader<I extends Item, K extends keyof TypeMap = keyof TypeMap> = {
      [P in K]: {
        compare: (a: TypeMap[P], b: TypeMap[P]) => number;
        filter: (value: TypeMap[P], input: TypeMap[P]) => boolean;
        get: (item: I) => TypeMap[P] | null | undefined;
        type: P;
      };
    }[K];
    

    And now your types need to be generic for that mapping T:

    type TableHeader<I extends Item, T extends Record<keyof T, keyof TypeMap>> = {
      [K in keyof T]: FieldHeader<I, T[K]>
    }
    
    type Filter<I extends Item, T extends Record<keyof T, keyof TypeMap>> = {
      [K in keyof T]: TypeMap[T[K]];
    }
    

    And so you'll need to specify that when declaring variables of these types (you can use utility functions to infer instead of manually specifying, but I won't digress on that):

    const tableHeader: TableHeader<MyItem, { selected: "boolean" }> = {
      selected: {
        compare: (a, b) => +a - +b,
        filter: (value, input) => value === input,
        get: (video) => video.selected,
        type: 'boolean',
      },
    };
    
    const filter: Filter<MyItem, { selected: "boolean" }> = {
      // ts should throw an error here, as type of "selected" should be 'boolean'
      selected: "",
    
      // also only props of "tableHeader" should be allowed
      foo: 123
    }
    

    And finally your applyFilter() function is also generic in T like this:

    function applyFilter<I extends Item, T extends Record<keyof T, keyof TypeMap>>(
      item: I, tableHeader: TableHeader<I, T>, filter: Filter<I, T>
    ) {
      (Object.keys(filter) as Array<keyof T>).forEach(<K extends keyof T>(key: K) => {
        const input = filter[key];
        const value = tableHeader[key].get(item);
        if (value != null) tableHeader[key].filter(value, input);
      });
    }
    

    Note that we still need a type assertion for the compiler to see that filter's entries has anything to do with keyof T, and in fact we can't use Object.entries() because that won't let us represent the relationship between K and Filter<I, T>[K] needed. But otherwise it works; the compiler can see that input is of type TypeMap[T[K]], and that value is of type TypeMap[T[K]] (after being checked for nullishness) and that tableHeader[key].filter is of type (value: TypeMap[T[K]], input: TypeMap[T[K]]) => boolean, so everything type checks.

    Playground link to code