Search code examples
typescripttuplestypescript-generics

How to do you use tuples with generics in TypeScript?


I am trying to write a type safe map object. I want to define my key/value pairs once and only once.

I have succeeded with the following:

const myPropTuple = [
    [0, "cat"],
    [1, "dog"],
    [2, "bird"]
] as const;

type TMyPropKey = TInnerTupple<typeof myPropTuple>[0];
type TMyPropValue = TInnerTupple<typeof myPropTuple>[1];

function getMyProp(val: number) {
    type TKey = TInnerTupple<typeof myPropTuple>[0];

    const mapA2 = new Map(myPropTuple);
    if(!mapA2.has(val as TKey)) throw new RangeError("unexpected value");
    return mapA2.get(val as TKey);
}

// helper library (not to be inlined)
type TTupleType<T extends Iterable<any>> = T extends ReadonlyArray<infer R> ? R :never;
type TInnerTupple<I extends Iterable<any>> = TTupleType<I>;

// Tests
console.assert(getMyProp(1) === "dog");
//console.assert(getMyProp(1) === "turle");    // throws compiler error

const a: TMyPropValue = "cat";
//const b: TMyPropValue = "eagle";    // throws compiler error

But I would like make the function generic and still maintain type safety:

The goal is to be able to write

const myPropTuple = [
    [0, "cat"],
    [1, "dog"],
    [2, "bird"]
] as const;

console.assert(getGenericProp(myPropTuple, 1) === "dog");

const yourPropTuple = [
    [0, "fish"],
    [1, "towel"],
    [2, "whale"]
] as const;

console.assert(getGenericProp(yourPropTuple, 0) === "fish");

and for the following to fail to compile

console.assert(getGenericProp(myPropTuple, 1) === "turle");    // throws compiler error

type TYourPropValue = TInnerTupple<typeof yourPropTuple>[1];
const y: TYourPropValue = "dog";    // throws compiler error

addendum


@jcalz suggested an alternate solution which has the cardinal virtue of simplicity, so I will repeat it here slightly expanded:

const animalProps = {
    0: "cat",
    1: "dog",
    2: "bird"
  } as const;

function getGenericProp2<T extends object>(props: T, val: keyof T): T[keyof T] {
    const found = props[val];
    if(found === undefined) throw new RangeError("unexpected value");
    return found;
}
type TValues = ValueOf<typeof animalProps>;
type TKeys = keyof typeof animalProps;

Solution

  • You could write it this way:

    function getGenericProp<T extends readonly [any, any]>(
      propTuple: readonly T[], val: T[0]
    ) {
      const mapA2: Map<T[0], T[1]> = new Map(propTuple);
      if (!mapA2.has(val)) throw new RangeError("unexpected value");
      return mapA2.get(val);
    }
    

    It is generic in the type of the elements of propTuple, which we expect to be a union of readonly 2-tuples. The type of val is T[0], one of the first elements of each pair inside propTuple. And the return type is T[1] | undefined. T[1] means one of the second elements of each pair inside propTuple, and the undefined happens because Map.get() can return undefined regardless of whether you've checked has() first (see Why `map.has()` doesn't act as a type guard. This was also true of your non-generic version, so addressing it is out of scope here).

    Anyway you can verify that it works as desired:

    const myPropTuple = [
      [0, "cat"],
      [1, "dog"],
      [2, "bird"]
    ] as const;
    console.assert(getGenericProp(myPropTuple, 1) === "dog"); // okay
    
    const yourPropTuple = [
      [0, "fish"],
      [1, "towel"],
      [2, "whale"]
    ] as const;
    console.assert(getGenericProp(yourPropTuple, 0) === "fish"); // okay
    
    console.assert(getGenericProp(myPropTuple, 1) === "turtle"); // error!
    // This comparison appears to be unintentional because the types 
    // '"cat" | "dog" | "bird" | undefined' and '"turtle"' have no overlap.
    
    getGenericProp(myPropTuple, 3) // error!
    // Argument of type '3' is not assignable to parameter of type '0 | 2 | 1'.
    

    Looks good.


    Note that the function doesn't know which of the outputs are returned for a particular input, and such a thing would be possible... but Maps aren't really meant for that. You could try refactoring Map's typings to pay attention to that, as shown in Typescript: How can I make entries in an ES6 Map based on an object key/value type, but that suggests instead of a tuple of tuples you really just want an object:

    const obj = {
      0: "cat",
      1: "dog",
      2: "bird"
    } as const;
    const val = obj[1];
    // const val: "dog"
    

    but now you don't need a function at all, for just an indexing operation. But this is also out of scope for the question as asked.

    Playground link to code