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

Viewed 218

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.

5 Answers

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

If you need a type that checks if the original type is exactly transposed, you first need to use a const assertion as const on the original (or types of elements of mutable arrays under value and label keys will be widened).


There is a way to type the transpose without resorting to generating indices which results in a more concise and robust type (the rest of the logic is the same as in the utility below):

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

type T2<T extends { value: readonly number[], label: readonly string[] }> = {
    [ P in keyof Omit<T["value"], keyof readonly any[]> ] : { value: T["value"][P], label: P extends keyof T["label"] ? T["label"][P] : never }
}  & readonly any[];

type test = T2<typeof original>;

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

Playground


Recursive version

Next, you have to generate enough indices to ensure the elements in the resulting tuple are positioned correctly. This can be done with recursive conditional types, but beware of the recursion depth limits (~23 in this implementation):

type GenIndices<L extends number, A extends any[] = []> = Omit<A["length"] extends L ? A : GenIndices<L, [...A, A["length"]]>, keyof any[]>;

type test = GenIndices<3>; //{0: 0; 1: 1; 2: 2; }

Finally, you need to map the indices to the corresponding values in tuples under the keys of the original type (note that unless you guarantee that P extends keyof T[<key here>], you will not be able to use indices to index the properties):

type Transpose<T extends { value: readonly number[], label: readonly string[] }> = {
    [ P in keyof GenIndices< T["value"]["length"] > ] : P extends keyof T["value"] ? 
    P extends keyof T["label"] ? 
    { value: T["value"][P], label: T["label"][P] } : 
    never : 
    never;
} & readonly any[];

That's it, let's test how the utility works:

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

//Transpose implemenetation

type test = Transpose<typeof original>;

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

const err: test = [ 
    {value: 2, label: "one"}, //Type '2' is not assignable to type '1'
    {value: 2, label: "two"},
    { label: "three" } //Property 'value' is missing
];

And the transpose function could look like this:

declare function tranpose<T extends typeof original>(original: T): Transpose<T>;
const test2 = tranpose(original)[2]; //{ value: 3; label: "three"; }

Playground

I get, this question is about the typing, not the implementation of that function.

function tranpose<T extends Record<keyof any, readonly any[]>>(original: T): {
    [K in keyof T]: T[K] extends readonly (infer U)[] ? U : never
}[] {
    // implementation is up to you
    return [];
}

TS Playground

or as a type

type Transpose<T extends Record<keyof any, readonly any[]>> = {
    [K in keyof T]: T[K] extends readonly (infer U)[] ? U : never
}[];

This doesn't care what or how many properties the input has, as long as they are some kind of array.

What it can't do is map the respective indices in these arrays or ensure that all arrays have the same length, that's something you have to deal with when implementing the function.

Here is alternative veriosn:



type Origin = Readonly<{
    value: ReadonlyArray<any>,
    label: ReadonlyArray<any>
}>
type Reduce<T extends Origin, Result extends ReadonlyArray<any> = []> =
    T extends { value: [], label: [] }
    ? Result
    : T extends { value: [infer V], label: [infer L] }
    ? [...Result, { value: V, label: L }]
    : T extends { value: [infer Val, ...infer Vals], label: [infer Label, ...infer Labels] }
    ? Reduce<{ value: Vals, label: Labels }, [...Result, { label: Label, value: Val }]>
    : Result


type Test = Reduce<{ label: [1, 2, 3], value: ['1','2', '3'] }>

Playground

You could use generics in the types of the object fields

function transpose<A,B>(original:{value:A[], label:B[]}): { value:A, label:B }[] {
    const objs:  {value:A, label:B}[] = [];
    original.value.forEach( (value, i) => {
        const label = original.label[i];
        objs.push({label, value}); 
    });
    return objs;
}

const obj = {
    value: [ 1,2,3 ],
    label: ['One', 'Two', 'Three']
};

console.log(transpose(obj));

Playground link

Related