I wouldn't try to go the route of outputting a particular tuple ordering. As you already noted, the actual result might not be in that order, so it would be misleading to present it as such a type. Lying to the compiler is sometimes necessary or useful, but in this case I don't see a major benefit.
Furthermore, even if I wanted to do this, it's actually not easy to get the compiler to turn a union like keyof T
into an ordered tuple. The type "a"|"b"
is the same exact type as "b"|"a"
; the compiler may very well use one or the other or both without letting you know, and so anything you do that produces ["a", "b"]
vs ["b", "a"]
from that is likely to switch around when you don't expect it. You can abuse the type system to make this happen, but it's really messy and fragile and I recommend against it.
If you really want to use tuples, you could avoid the ordering issue by turning a union like "a"|"b"
into a union of all possible tuples like ["a", "b"] | ["b", "a"]
. That is actually a bit easier to represent in the type system because it's symmetric over the union members, but is still messy because once you have a decent number of properties the number of elements in the union becomes unmanageable (yay factorial). The upside here is that you are really and truly as honest as possible about the output type. Here's one way to implement it:
type UnionToAllPossibleTuples<T, U = T> = [T] extends [never]
? []
: T extends unknown ? [T, ...UnionToAllPossibleTuples<Exclude<U, T>>] : never;
type MergedColumns<T> = UnionToAllPossibleTuples<
{ [K in keyof T]: { key: K; val: T[K] } }[keyof T]
>;
type Lookup<T, K> = K extends keyof T ? T[K] : never;
type UnmergeColumns<T> = T extends any
? [
{ [K in keyof T]: Lookup<T[K], "key"> },
...{ [K in keyof T]: Lookup<T[K], "val"> }[]
]
: never;
type Columns<T> = UnmergeColumns<MergedColumns<T>>;
And you can verify this works:
interface TestType {
key: string;
life: number;
goodbye: boolean;
}
type ColumnsTestType = Columns<TestType>;
// type ColumnsTestType =
// | [["key", "life", "goodbye"], ...[string, number, boolean][]]
// | [["key", "goodbye", "life"], ...[string, boolean, number][]]
// | [["life", "key", "goodbye"], ...[number, string, boolean][]]
// | [["life", "goodbye", "key"], ...[number, boolean, string][]]
// | [["goodbye", "key", "life"], ...[boolean, string, number][]]
// | [["goodbye", "life", "key"], ...[boolean, number, string][]]
That's fun, but probably still too fragile and messy to be something I'd recommend.
Backing up, it seems like the thing you really care about is preserving the type T
across toCsv()
and toArray()
, and that the original array type, while accurate, was lossy. In that case, how about this minor change to your original code?
type Columns<T> = [Key<T>[], ...T[Key<T>][][]] & { __original?: T };
Here, Columns<T>
is essentially the same type as before but has an optional extra property named original
with the type T
. This property will never actually be present or used at runtime. Yes, you are possibly deceiving the compiler here but not actually lying; the stuff coming out of toCsv()
will have no __original
property, which does match {__original?: T}
. The deception is useful though, since it gives the compiler enough information to understand what happens on the round trip. Observe:
const values = [{ key: "value", life: 42, goodbye: false }];
const csv = toCsv(values);
// const csv: Columns<{ key: string; life: number; goodbye: boolean; }>
const original = toArray(csv);
// const original: { key: string; life: number; goodbye: boolean; }[]
That looks good to me and what I'd recommend.
RECAP: If you want to lie to the compiler, don't lie about tuple order. Telling the truth about tuple order is too messy. Instead, tell a small lie about an optional property.
Okay, hope that helps. Good luck!
Link to code