2

I want to convert an object into an array of Key/Value pairings. For instance:

const dict = { foo: 1, bar: 2, baz: "hi" };
const arr = dictToArray(dict);

Where arr would be transformed into a type strong representation that looks something like:

[
  ["foo", { foo: 1 }],
  ["bar", { bar: 2 }],
  ["baz", { baz: "hi" }]
]

This is quite simple to do from a runtime standpoint but i'm struggling to get the typing to come along for the ride. My implementation of the conversion is:

export function dictToArray<T extends NonNullable<{}>>(obj: T): DictArray<T> {
  const out: any = [];

  for (const key of Object.keys(obj)) {
    out.push([key, { [key]: obj[key as keyof T] }]);
  }

  return out as DictArray<T>;
}

Where the utility functions/types are:

export type DictArray<T> = [keyof T, Pick<T, keyof T>][];

I presume what's failing is the definition of DictArray and in particular the Pick reference. I say this because each tuple in the array does consist of the correct run time data but the second element of the tuple which is the key-value pair is typed as the full object T.

In an object assignment I'd map over each prop with a {[K in keyof T]: V } type of assignment but I'm not sure what the comparable syntax would be for an array. I'm guessing somewhere in there lies the answer.

Typescript Playground

ken
  • 8,763
  • 11
  • 72
  • 133
  • I'm not sure this can be done for an array of indefinite length. You can definitely create fixed-length arrays where each element of the array has a definite (an different) type, but I think it would require some kind of run-time typing (which TypeScript obviously doesn't support) for an unpredictable mixed types to be copied from the assorted type in the original object. – kshetline Jun 06 '21 at 00:28
  • @kshetline it may be ... but it is possible when inferring to an object's keys which are also an unknown length ... it's 6 of one, half dozen of another ... but that doesn't mean that TS supports it I guess. – ken Jun 06 '21 at 00:33
  • I'm not sure what's going on with some of the code; `NonNullable<{}>` is just `{}`; you might want to pare this down to a [mcve] so that others don't get distracted with what seems to be unrelated code. The same goes for `Keys` function (aside: why not `keys`) using a `K` that has no inference site; why not just use `keyof T` directly? Unless you're asking about this stuff it's not clear why it's part of the question, other than it happens to be some of your other code. In my answer I will probably rewrite these but not explain the rewrites since they are off topic for the question. – jcalz Jun 06 '21 at 01:19
  • @jcalz `Keys` tells the type system that it is keyof T versus Object.keys(T) which does not. As for NonNullable, I have removed it but it's just that in JS `null` is considered an "object" from a typeof perspective but I guess this doesn't pass through to TS. – ken Jun 06 '21 at 01:25
  • @jcalz both are now removed (including playground link) – ken Jun 06 '21 at 01:33

1 Answers1

2

Note that the desired dictToArray function type can run afoul of the problem mentioned in Why doesn't Object.keys return a keyof type in TypeScript?, whereby an object types in TypeScript are open and values can contain more properties at runtime than are known to the compiler. This:

function keys<T extends object>(obj: T) {
  return Object.keys(obj) as Array<keyof T>;
}

will work just fine when you pass it an object literal, but have possibly unexpected results if you pass it an instance of a class or interface which has been extended with extra properties. As long as you are aware of this problem and willing to face the consequences if they arise, great.


Anyway, my inclination here for a dictToArray() typing would look like this:

type DictArray<T extends object> = 
  Array<{ [K in keyof T]: [K, Pick<T, K>] }[keyof T]>;

 declare function dictToArray<T extends object>(obj: T): DictArray<T>;

We are making a mapped type which walks through each key K in keyof T and generates the tuple [K, Pick<T, K>] for it. If T looks like {x: 1, y: 2}, this makes something like {x: ["x", {x: 1}], y: ["y", {y: 2}]}, which has the property values you want but is still nested inside an object type... so we index into this object with keyof T to get the desired union ["x", {x: 1}] | ["y", {y: 2}].

You can verify that this gives you a reasonable type:

const arr = dictToArray(dict);
/* const arr: DictArray<{
    foo: number;
    bar: number;
    baz: string;
}> */

Oh, well, that doesn't actually show much. Let's force the compiler to expand that out:

type ExpandRecursively<T> = 
  T extends object ? { [K in keyof T]: ExpandRecursively<T[K]> } : T;
type ArrType = ExpandRecursively<typeof arr>;
/* type ArrType = (
  ["foo", { foo: number; }] | 
  ["bar", { bar: number; }] | 
  ["baz", { baz: string; }]
  )[] */

So, you can see that arr has a type which is an array of strongly typed tuples. Hooray!


Oh, wait, I see that you want this type:

[
  ["foo", { foo: 1 }],
  ["bar", { bar: 2 }],
  ["baz", { baz: "hi" }]
]

which, aside from the fact that the compiler has no knowledge of the literal values of the object properties (you let the compiler infer the types so they are widened to number and string as opposed to 1, 2, or "hi")... ugh, I digress.


I see that you want this type:

[
  ["foo", { foo: number }],
  ["bar", { bar: number }],
  ["baz", { baz: string }]
]

which seems to represent the order of the entries that come out. Object property order is technically observable in JavaScript, although it doesn't always behave the way people expect (Does JavaScript guarantee object property order?). But in TypeScript types, object property order is not supposed to be observable. The type {foo: number, bar: number, baz: string} is equivalent to the type {bar: number, baz: string, foo: number}. There's no type system test you should be doing which could distinguish one from the other. It is sometimes possible to tease order information out of the type system, but it is a terrible idea because it will be nondeterministic (How to transform union type to tuple type).

So there's no principled way to ask the compiler to give you the above type as opposed to, say,

[
  ["foo", { foo: number }],
  ["baz", { baz: string }],
  ["bar", { bar: number }]
]

or any other ordering. It is within the realm of conceivability that we could make DictArray<T> give you the union of all possible orderings, but this would scale very badly and probably not be particularly usable even for small cases. So unless you are morbidly curious about how to generate that, I won't bother.


Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • 1
    Great solution (but I'm starting to realize that's what I should I expect from you). The ordering isn't a concern for me ... i'd never expect to get an order out of object keys. I added the key as a separate element to aid in some filtering and referencing of the Key/Value. – ken Jun 06 '21 at 02:21