Search code examples
typescripttypescript-genericstypescript-types

how do I map (translate) a tuple to another tuple in TypeScript?


I'm trying to map a tuple to another tuple based on a name map and get a strongly typed tuple as the result.

In some cases a single element from the source tuple may map into multiple elements in the result. Also, the source tuple may have variable length.

this is what I have for now:

const t1 = ["a", "b", "c"] as const;
const nameMap = {
    "a": [],
    "b": ["j"],
    "c": ["k", "l"],
} as const;
const t2 = t1.reduce<string[]>((acc, e) => {
    acc.push(...nameMap[e]);
    return acc;
    }, []);

However, "t2" is an array here, not a strongly typed tuple. How do I get t2 to be a strongly typed tuple (["j", "k", "l"] in this case)?

(order does not matter)


Solution

  • For this to work you'll need to write a recursive conditional type that walks through the tuple type of the input array and maps them to another array using the name mapper, and builds up an output by concatenating these arrays.

    For example, here's a tail-recursive conditional type called MapNames<T, M> which takes an input tuple T and a mapping type M that converts the elements of T into a new array:

    type MapNames<
        T extends readonly string[],
        M extends Record<T[number], readonly any[]>,
        A extends any[] = []
    > = T extends readonly [
        infer F extends keyof M,
        ... infer R extends readonly string[]
    ] ? MapNames<R, M, [...A, ...M[F]]> : A
    

    It uses variadic tuple types to manipulate both the input and output tuples. Note that the third type parameter A is the accumulator and its use makes it tail-recursive and therefore amenable to fairly long input tuples. We break the input tuple T into the first element F and the rest of the elements R. Then F is a key of the mapping type F, and we concatenate the array M[F] to the end of the accumulator, and recurse.

    Then you can write a generic mapNames() function that takes inputs of types T and an M and returns a MapNames<T, M>:

    function mapNames<
        const T extends readonly string[],
        const M extends Record<T[number], readonly any[]>
    >(t: T, m: M): MapNames<T, M> {
        return t.reduce<string[]>((acc, e) => {
            acc.push(...(m as any)[e]);
            return acc;
        }, []) as any;
    }
    

    This function is implemented the same way as your example, although there's no way for the compiler to understand that the implementation works for such a complicated recursive conditional generic type. So we have to loosen type safety inside the function via type assertions and the any type, or something like it.


    Let's test it out:

    const t1 = ["a", "b", "c"] as const;
    const nameMap = {
        "a": [],
        "b": ["j"],
        "c": ["k", "l"],
    } as const;
    const t2 = mapNames(t1, nameMap);
    //    ^? const t2: ["j", "k", "l"]
    console.log(t2); // ["j", "k", "l"]
    

    Looks good. The type of t2 matches the actual value at runtime.

    Playground link to code