Search code examples
typescripttypescript-genericsmapped-types

How to declare a "transposed" version of a type?


I would like to define a TS function to transpose an object to array for instance:

const original  = { 
    value: [1, 2, 3], 
    label: ["one", "two", "three"] 
}

const transposed = [ 
    {value: 1, label: "one"}, 
    {value: 2, label: "two"},
    {value: 3, label: "three"}
]

Then I would like to declare a function that receives the original and outputs transposed and vice versa, like:

function tranpose(original: T): Array<T> // Incorrect, the output should be a transposed version of the object.

How can a define the transposed version of the object and correctly declare the function?

--

PS I'm not asking about implementations, just about the typing and declaration.


Solution

  • Heh, you want Transpose<Transpose<T>> to yield something like T, right? And I'm assuming, although it's not explicit in the question, that you want this to work for arbitrary objects-or-arrays which contain objects-or-arrays, not specifically "things with value and label properties that contain arrays".

    That is conceptually easy, but things get hairy when dealing with objects-vs-arrays. Even though mapped types should produce array/tuples from array/tuples, there are pitfalls where the compiler doesn't realize it's mapping an array/tuple until it's too late and your mapped type is full of awful array methods like "length" | "push" | "pop" |.... I assume your implementation will also have some hairiness with this, but I'm worried about types here, not the implementation.

    Here's my version, which works except for bit of weirdness with IntelliSense showing a union of identical types (like type Foo = "a" | "a" | "a" where you'd expect to see type Foo = "a"), which luckily doesn't affect how the types behave:

    type Transpose<T> =
        T[Extract<keyof T, T extends readonly any[] ? number : unknown>] extends
        infer V ? { [K in keyof V]: { [L in keyof T]:
            K extends keyof T[L] ? T[L][K] : undefined } } : never;
    
    declare function transpose<T>(t: T): Transpose<T>;
    

    The explanation is that we are walking though the value/element types of T to figure out what the keys of the output type should be. That should be T[keyof T] but we need to do T[Extract<keyof T, ...] to make sure that arrays don't mess things up. Then we mostly just turn T[K][L] into T[L][K] with some type checks along the way.


    Let's test it. We need a const assertion or something like it if you want the compiler to keep track of which values are at which keys:

    const original = {
        value: [1, 2, 3],
        label: ["one", "two", "three"]
    } as const
    /* const original: {
        readonly value: readonly [1, 2, 3];
        readonly label: readonly ["one", "two", "three"];
    } */
    

    Now we'll transpose it:

    const transposed = transpose(original);
    /* readonly [{
        readonly value: 1;
        readonly label: "one";
    }, {
        readonly value: 2;
        readonly label: "two";
    }, {
        readonly value: 3;
        readonly label: "three";
    }]
    */
    
    
    transposed.forEach(v => v.label) // transposed is seen as an array
    transposed[1].label // known to be "two"
    

    Looks good. If you use IntelliSense on transposed you will see it as a union of three identical types, but 🤷‍♂️. (This is a known design limitation of TypeScript; see microsoft/TypeScript#16582. It's possible to force the compiler to aggressively reduce unions, as shown here, but that isn't really the point of this question, so I digress.) The output type is seen as a tuple, so it has all the array methods, which I assume you want.

    Then we're supposed to be able to obtain the original again by transposing the transposed thing:

    const reOriginal = transpose(transposed);
    /* const reOriginal: {
        readonly value: readonly [1, 2, 3];
        readonly label: readonly ["one", "two", "three"];
    } */
    
    reOriginal.label.map(x => x + "!"); // reOriginal.label is seen as an array
    

    Again, looks good. The type of reOriginal is (modulo IntelliSense) the same as the type of original. Hooray!


    Playground link to code

    Playground link to code with IntelliSense fix