2

I have a dictionary object, very simple:

const mydict = {  
  key1: "value1",
  key2: "value2"
}

how do I make a type that automatically converts an object like this into entries?

const myentries: MagicType<typeof mydict> = Object.entries(mydict)

such that actual type of myentries is

type myentriesT = [
  ["key1", "value1"],
  ["key2", "value2"]
]

instead of the garbage

[string, string][]

that ts does with Object.entries rn

8
  • 1
    The duplicate link does not provide an answer that fully matches what is being sought. The author needs something like this: tsplay.dev/Wzr1LW Commented Oct 3 at 2:41
  • @Alexandroppolus I've reopened the question, but in fairness, you're just using a type assertion to coerce the result of Object.entries() into your magic type. Commented Oct 3 at 2:49
  • 2
    • You need as const to preserve "value1" and "value2". • You can write a fairly straightforward type that gives you an unordered array like (["key1","value1"] | ["key2", "value2"])[] as shown in this playground link. • It is a VERY BAD IDEA to try to coerce TS to give you an ordered array since the type system doesn't preserve this information for object types, as per this question. You can try, but it's very fragile. Commented Oct 3 at 3:28
  • 2
    @Alexandroppolus it's a very bad idea to try to turn unions into tuples. You cannot count on unions in TS to have a stable or predictable order. TS caches types, and this cache can affect the order, even of a "fresh" object literal. See this playground link which uses your approach. And uncomment line 7 to see it break. It's unfortunately irresponsible to suggest union-to-tuple without huge caveats. Commented Oct 3 at 3:41
  • 1
    I have to go to bed but I'll follow up tomorrow. But as for why the recursive conditional type is "bad", see the playground link and uncomment line 7. The seemingly unrelated line const unrelatedStuff = "key2"; changes the order TS iterates the keys of the mydict, and therefore you now get a tuple which is completely wrong. You simply cannot trust TS to accurately reflect the order of keys in an object. Does that make sense? Commented Oct 3 at 5:20

1 Answer 1

2

Why doesn't Object.entries() return a more precise type in TypeScript?

The return type of Object.entries() in TypeScript is intentionally kept to be wide, like [string, any][] or [string, T][] (where T is the effective string index signature property value type, which might be an implicit index signature) because object types in TypeScript are open and extendible, not closed, sealed, or "exact" (a term from Flow). A value of type {key1: string, key2: string} might well have more properties than just key1 and key2. So you'd run into a problem with code like:

function doStuffWithEntries(obj: { x: number, y: string }) {
    for (const [k, v] of Object.entries(obj)) {
        console.log(k === "x" ? v.toFixed() : v.toUpperCase()); 
    }
}
doStuffWithEntries({ x: 0, y: "a" }) // "0", "A"
const p = { x: 0, y: "a", z: 0 };
doStuffWithEntries(p); // "0", "A", and then RUNTIME ERROR!

Notice that p is considered to be a value of type {x: number, y: string}, even though it has that z property also. So the above code would have no compile-time error, but then at runtime you get an explosion when you try to call toUpperCase() on 0.

There is a longstanding feature request for closed/sealed/exact types at microsoft/TypeScript#12936 but until and unless it is implemented, you will run into situations where TypeScript stops you from doing something because excess properties would break it, or situations where it allows you to do this but then is unsafe. The correct thing: allowing you to exhaustively enumerate keys on exact types, but preventing you from doing it on inexact types... this is currently impossible.

See Preserve Type when using Object.entries and this comment on microsoft/TypeScript#12253 for more information.

That's the main reason why TypeScript doesn't already do this.


There is also the issue where making call signatures more precise isn't always worth making them more complicated. Call signatures that work well for specific types can become a nightmare for generic types. A type like [string, T[string]][] is easy to reason about in the abstract, whereas something like {[K in keyof T]: [K, T[K]]}[keyof T][] is much less tractable, and so people who use Object.entries() in generic contexts might be unhappy if their code suddenly breaks with errors about that type. And more complicated typings also make the compiler work harder, so it would have to be a huge benefit to outweigh a moderate performance cost. For example, the array map() method doesn't preserve tuple length. This should be "easy" to do, but if you look at microsoft/TypeScript#29841 you'll see that attempts to do this run into various problems with performance and code breakage.


How can I get TypeScript to keep track of the literal property values in an object literal?

In

const myDict = {
    key1: "value1",
    key2: "value2"
}
/* const myDict: {
    key1: string;
    key2: string;
} */

the property types are string and not literal types like "value1" or "value2". This is usually desirable because people often change property values, like myDict.key2 = "value3". TypeScript has to infer some type for myDict and it uses a heuristic that works well in a wide range of real world code.

If you don't plan to change property values, you can tell TypeScript this with a const assertion, to get more specific types:

const myDict = {
    key1: "value1",
    key2: "value2"
} as const; 
/* const myDict: {
    readonly key1: "value1";
    readonly key2: "value2";
} */

Now the properties are considered readonly and they have precise literal value types. From here you could go on to try to construct an Object.entries() output type that's closer to what you want.


An unordered array of strongly-typed key-value tuples for Object.entries():

Assuming you don't care about inexact/exact types and you want to tell TypeScript to compute a type for Object.entries() that you either merge into your code base or wrap in a helper function like

function objectEntries<T extends object>(obj: T): Entries<T> {
    return Object.entries(obj) as any;
}

what should the type Entries<T> be? The closest you can realistically get to your goal is to make it an unordered array. This is described in Preserve Type when using Object.entries, and could look like this:

type Entries<T> = { [K in keyof T]: [K, T[K]] }[keyof T][]

This uses a distributive object type (as coined in microsoft/TypeScript#47109) that walks through each key K of T, generates the entry tuple [K, T[K]], then gets the union of these and returns an unordered array.

So that gives you

const myEntries = objectEntries(myDict);
//    ^? const myEntries: (["key1", "value1"] | ["key2", "value2"])[]

Yes, the same caveats apply when myDict ends up having extra properties that TypeScript doesn't know about, but we're ignoring that and assuming no such extra properties can exist. Then we know that myEntries is some array of tuples where either the first element is "key1" and the second is "value1", or the first element is "key2" and the second is "value2".

This is close to what you asked for, but it's unordered.


Why can't you get an ordered tuple of tuples?

See How to transform union type to tuple type. The problem is that a TypeScript object type like {x: 0, y: 1} doesn't care about the order of the properties. The type is identical to {y: 1, x: 0}. The keys of that type is the union "x" | "y", but TypeScript union types don't care about the order of the members. So that is identical to the union "y" | "x". There is no way in principle to distinguish those from each other. So even if you have an object that, at runtime, has keys in a certain order, TypeScript cannot inspect the type to determine the order.

Of course TypeScript actually internally represents unions and object keys in some order, but that order is not meant to be observed. You can actually tease that information out of TypeScript, using some dirty tricks, but that information is useless to you. It only tells you the order TypeScript happens to be using, and TypeScript feels free to reorder it based on whatever needs it has. It's a VERY BAD IDEA to do this: it's unsupported, it's fragile, and it's useless. Let's see.

Here's a possible way to do it:

type LastEntry<T> = {
    [K in keyof T]: (x: (x: [K, T[K]]) => void) => void
}[keyof T] extends (x: infer O) => void ?
    O extends (x: infer E) => void ? E : unknown : never

type EntryTuple<T, R extends any[] = []> =
    LastEntry<T> extends [infer K extends PropertyKey, infer V] ?
    EntryTuple<Omit<T, K>, [[K, V], ...R]> : R

function objectEntryTuple<T extends object>(obj: T): EntryTuple<T> {
    return Object.entries(obj) as any
}

I'm not going to go into how that works; you can read the answer to How to transform union type to tuple type for details. Anyway, if you use it you get this:

const o = { xxx: 123, yyy: "abc" } as const;
const oEntryTuple = objectEntryTuple(o);
// const oEntryTuple: [["xxx", 123], ["yyy", "abc"]]

Looks good, right? TypeScript happens to represent the internal ordering of o's properties the same way you wrote it. But now look what happens if you make a seemingly unrelated change:

const unrelatedThing = "yyy";
// ... later ...
const o = { xxx: 123, yyy: "abc" } as const;
const oEntryTuple = objectEntryTuple(o);
// [["yyy", "abc"], ["xxx", 123]]

Oopsie! The order changed. So even though at runtime you can expect the first entry key to be "xxx", your TypeScript function says it's "yyy", and you can get fun runtime errors:

console.log(oEntryTuple[0][1].toUpperCase()); // compiles okay, runtime error

This is not a bug in TypeScript. It is intentional behavior. Objects and unions are intended to be unordered. If you manage to force TypeScript to tell you the order it happens to use, there's not much you can do with that information. If you report this as a bug, the TypeScript language maintainers will tell you it's unsupported and probably get annoyed at me for even mentioning it here. The only bug is in code that pretends the order is reliable... namely, the objectEntryTuple() function above.

Since we can't rely on the ordering, it's best not to use a tuple. Yes, it's possible to rewrite the code so that you get an unordered tuple so that you at least get the length of the array... but this added complexity is hardly worth it.


What should you do instead if you care about the order##

Personally if you care about such orderings you should just start with the tuple. It's more tractable (yet similarly not directly implemented due to inexact types) to give a strong typing to Object.fromEntries() instead:

function objectFromEntries<T extends readonly [PropertyKey, any]>(
    entries: Iterable<T>): { [U in T as U[0]]: U[1] } {
    return Object.fromEntries(entries) as any;
}

Constructing the object type throws away the entry order, but you don't care because you have the entry order already. This is much easier and more likely to be successful than starting with the object type and trying to dig the entry order out of the trash. Let's try it:

const myEntries = [["key1", "value1"], ["key2", "value2"]] as const;
// const myEntries: readonly [readonly ["key1", "value1"], readonly ["key2", "value2"]]

const myDict = objectFromEntries(myEntries);
/* const myDict: {
    key1: "value1";
    key2: "value2";
} */
console.log(myDict); // {key1: "value1"; key2: "value2"}

Looks good. Your myEntries variable has the desired tuple type, and will always have it. The myDict object created from it is of the expected object type. If you ever need to iterate over the entries, use myEntries.

Playground link to code

Sign up to request clarification or add additional context in comments.

1 Comment

Amazing, thank you so much for your time! Even as it may be disappointing to observe limitations of a technology, seeing people's efforts to address these issues does make my day better!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.