Search code examples
typescriptgenericstype-safety

Problem with inferring correct types from generic for arrays


I have a hard time figuring out how to narrow inferred types in the following example. For now, all format functions accept string | number | boolean and expect to return string | number | boolean.

Ideally, I would like to narrow these to only 1 type based on value of typeID.

enum TypeID {
  Number = "__number__",
  String = "__string__",
  Boolean = "__boolean__"
}

type Type<TYPE_ID extends TypeID> = {
  [TypeID.Number]: number;
  [TypeID.String]: string;
  [TypeID.Boolean]: boolean;
}[TYPE_ID];

type Item<
  TYPE_ID extends TypeID,
  TYPE extends Type<TYPE_ID> = Type<TYPE_ID>
> = {
  typeID: TypeID;
  format: (input: TYPE) => TYPE;
};

type Options<TYPE_ID extends TypeID> = Array<Item<TYPE_ID>>;

const someFunc = <TYPE_ID extends TypeID>(options: Options<TYPE_ID>) => {
  return null as any;
};

someFunc([
  {
    typeID: TypeID.Number,
    format: input => input // these should have type "number"
  },
  {
    typeID: TypeID.Boolean,
    format: input => input // these should have type "boolean"
  },
  {
    typeID: TypeID.String,
    format: input => input // these should have type "string"
  }
]);

Solution

  • It's pretty confusing to have such similar type names (TYPE vs Type vs TYPE_ID vs TypeID) so I'm going to make the names distinct in what follows... for generic parameters I use one-or-two capital letters (which is the convention for whatever reason). Here's Type:

    type Type<T extends TypeID> = {
      [TypeID.Number]: number;
      [TypeID.String]: string;
      [TypeID.Boolean]: boolean;
    }[T];
    

    And here's Item:

    type Item<T extends TypeID> = {
      typeID: T; // <-- T, not TypeID
      format: (input: Type<T>) => Type<T>;
    };
    

    Note this is modified further from your example. You had two type parameters here, but only seemed to use a default value for the second one, so I removed it and used that default value. But the important modification here is that the typeID property is of the generic type T, not the concrete type TypeID. I think you probably meant to do this in your code, but maybe it was hard to see the difference.


    Now the way I'd go from here is to generate a type called SomeItem which is the union of all possible Item<T> types. This concrete type can be used more easily than generic types:

    type _SomeItem<T extends TypeID> = T extends any ? Item<T> : never;
    type SomeItem = _SomeItem<TypeID>;
    // type SomeItem = Item<TypeID.Number> | Item<TypeID.String> | Item<TypeID.Boolean>
    

    That worked by using a distributive conditional type in the definition of _SomeItem, to distribute Item<T> across the union of TypeID values.

    Finally, someFunc can be a concrete function:

    const someFunc = (options: Array<SomeItem>) => {
      return null as any;
    };
    

    And when you use it, you get the inference you expect:

    someFunc([
      {
        typeID: TypeID.Number,
        format: input => input // has type number
      },
      {
        typeID: TypeID.Boolean,
        format: input => input // has type boolean
      },
      {
        typeID: TypeID.String,
        format: input => input // has type string
      }
    ]);
    

    Okay, hope that helps. Good luck!
    Link to code