How can I define a tuple array with different tuple types explicitly in typescript?

Viewed 206

I'm writing a converter that converts a tuple array into an object using a lookup object that tells the function which strings map to which properties. However, I can't find a way to tell typescript that it's an array of tuples with specific types, the resulting tuples are always unions. Here's how it looks like:

interface EndObj {
    a: number; 
    b: string;
    c?: number;
}
interface InitObj {
    d: string;
    e: string;
    f: string;
}

const map = {
    d: 'a',
    e: 'b',
    f: 'c'
} as const;

type MapType = typeof map;
type ResultTuple<T extends keyof InitObj> = [T, EndObj[MapType[T]]];
type ResultTupleArray = ResultTuple<keyof InitObj>[];

const resultObj: ResultTupleArray = [['d', 1], ['e', 3], ['f', 3]]; // invalid! the value of 'e' should only allow strings

I think the reason why typescript allows this is, because ResultTupleArray is defined with keyof InitObj, so the resulting tuple array generic is always the same, so T will always be the same instead of specific per array entry, thus it can only be described with a union.

Here's how I found it out:

const undetected: ResultTuple<keyof InitObj> = ['e', 4]; // should be invalid
const detected: ResultTuple<'e'> = ['e', 4]; // actually shows an error for 4 (Type 'number' is not assignable to type 'string'.)

For a bit of context, here's how the converter looks like:

function mapInitToEnd(resultO: ResultTupleArray) {
    const endObj: EndObj = {
        a: -1,
        b: ''
    };

    for (const tuple of resultO) {
        const [key, val] = tuple;
        const mappedKey = map[key];
        endObj[mappedKey] = val;
    }
    return endObj;
}

Is there a way to tell typescript that the generic is only valid per entry in the tuple array, and not for the full array?

1 Answers

You want to distribute your ResultTuple definition so that if T is a union of keys, then the result is a union of tuples. The easiest way to accomplish this given your code is to make the definition a distributive conditional type, which gives you this behavior for free:

type ResultTuple<T extends keyof InitObj> = T extends any ? [T, EndObj[MapType[T]]] : never;

Distributive conditional types kick in when you have T extends U ? X : Y where T is a type parameter. So to get this to happen above we add the otherwise-useless T extends any ? ... : never check. Now your code will give you the error you expect:

const resultObj: ResultTupleArray = [['d', 1], ['e', 3], ['f', 3]];  // error!
// number not assignable to string ----------> ~~~~~~~~

There are other ways to get this behavior; for instance, by building a mapped type and immediately looking up its properties:

type ResultTuple<T extends keyof InitObj> = { [K in T]: [K, EndObj[MapType[K]]] }[T];

Either way should work.


Okay, hope that helps; good luck!

Playground link to code

Related