Objects as dictionaries
An object can be used as a lookup table: a variable number of entries, keys not known while you write the code, values all of one type. Before ES6 there was nothing else, so this is what everyone did, and a great deal of code still does.
A Map is usually better now. It takes any value as a key, it knows its own size, it iterates in insertion order, and it starts genuinely empty. An object still wins in three situations: a fixed table you write out by hand, a shape that has to survive JSON.stringify, and an API that hands you one. So this entry is both how to use an object as a dictionary properly and how to tell when you should not.
Create programs/objects-as-dictionaries.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
any string can be a key
Deno.test("any string can be a key", () => {
const table = {
"Can be any string!": 123,
["p" + "r" + "o" + "p"]: 456,
};
assertStrictEquals(table["Can be any string!"], 123);
assertStrictEquals(table.prop, 456);
assertStrictEquals(table["p" + "rop"], 456);
});
Check programs/objects-as-dictionaries.test.ts
running 1 test from ./programs/objects-as-dictionaries.test.ts
any string can be a key ... ok (0ms)
ok | 1 passed | 0 failed (1ms)
Quote a key and it can be any string, including one that could never be an identifier. Wrap it in square brackets and it can be any expression, evaluated as the object is built, and the same brackets read it back. Keys are strings or symbols, and nothing else: a key that is not one of those gets converted to a string, the rule the conversion and coercion page measured in a property key is always text, and it is where most of this entry's trouble starts.
asking whether a key is there
Deno.test("asking whether a key is there", () => {
const settings = { retries: 0, name: "" };
assert("retries" in settings);
assert(Object.hasOwn(settings, "retries"));
assertFalse(settings.retries ? true : false);
});
asking whether a key is there ... ok (0ms)
in and Object.hasOwn both answer the question, and they differ on inherited properties, which matters in a plain object is never empty. The third line is the mistake this step exists to retire: a truthiness check conflates "not there" with "there, holding something falsy", so a configured retries of 0 reads as absent, the trap the truthiness page measured in a truthiness check cannot tell missing from falsy.
delete removes the entry; undefined does not
Deno.test("delete removes the entry; undefined does not", () => {
const removed: Record<string, number | undefined> = { a: 1 };
const zeroed: Record<string, number | undefined> = { a: 1 };
delete removed.a;
zeroed.a = undefined;
assertEquals(Object.keys(removed), []);
assertEquals(Object.keys(zeroed), ["a"]);
assertStrictEquals(removed.a, undefined);
assertStrictEquals(zeroed.a, undefined);
assertFalse(Object.hasOwn(removed, "a"));
assert(Object.hasOwn(zeroed, "a"));
});
delete removes the entry; undefined does not ... ok (0ms)
Two objects, two ways of "getting rid of" a, and the middle pair of assertions shows why the difference is easy to miss: both reads produce undefined. delete leaves nothing behind, assignment leaves a key whose value is undefined, and only the listing in the first pair or the hasOwn in the last pair can tell you which one you are holding.
three views: keys, values, entries
Deno.test("three views: keys, values, entries", () => {
const prices = { apple: 3, bread: 4 };
assertEquals(Object.keys(prices), ["apple", "bread"]);
assertEquals(Object.values(prices), [3, 4]);
assertEquals(Object.entries(prices), [["apple", 3], ["bread", 4]]);
});
three views: keys, values, entries ... ok (0ms)
Three views of the same object, and all three consider only own, enumerable, string-keyed properties. Each of those three words is doing work, and the next two steps measure the first two of them.
a literal says yes three times
Deno.test("a literal says yes three times", () => {
const fromLiteral = { a: 1 };
assertEquals(Object.getOwnPropertyDescriptor(fromLiteral, "a"), {
value: 1,
writable: true,
enumerable: true,
configurable: true,
});
const defined = {};
Object.defineProperty(defined, "a", { value: 1 });
assertEquals(Object.getOwnPropertyDescriptor(defined, "a"), {
value: 1,
writable: false,
enumerable: false,
configurable: false,
});
const hidden = {};
Object.defineProperty(hidden, "secret", { value: 1 });
assertEquals(Object.keys(hidden), []);
assertEquals({ ...hidden }, {});
assertStrictEquals((hidden as { secret?: number }).secret, 1);
});
a literal says yes three times ... ok (0ms)
A property is not just a key and a value. It also has three attributes: writable, whether the value can change; enumerable, whether listing operations see it; and configurable, whether the attributes themselves can change or the property be deleted. The inversion is the thing to remember: a literal grants all three, and Object.defineProperty grants none unless you ask, so the low-level tool defaults to the locked-down answer, which is the opposite of what most people assume the first time they reach for it.
The hidden object shows what non-enumerable means in practice: invisible to Object.keys and to spreading, and perfectly readable if you know the name. That is how built-ins hide things, and it is worth having seen once so a property that "does not exist" but returns a value stops being a mystery.
four listing operations, four answers
Deno.test("four listing operations, four answers", () => {
const enumSym = Symbol("enumSym");
const nonEnumSym = Symbol("nonEnumSym");
const obj: Record<string | symbol, number> = {
enumString: 1,
[enumSym]: 2,
};
Object.defineProperty(obj, "nonEnumString", { value: 3 });
Object.defineProperty(obj, nonEnumSym, { value: 4 });
assertEquals(Object.keys(obj), ["enumString"]);
assertEquals(Object.getOwnPropertyNames(obj), [
"enumString",
"nonEnumString",
]);
assertEquals(Object.getOwnPropertySymbols(obj), [enumSym, nonEnumSym]);
assertStrictEquals(Reflect.ownKeys(obj).length, 4);
});
four listing operations, four answers ... ok (0ms)
Two independent distinctions, string against symbol and enumerable against not, give four combinations and four functions. Object.keys is the narrowest and the one you want almost always. Reflect.ownKeys is the only one that returns everything, which is its whole reason to exist outside of proxies. The naming is worth decoding once: in these APIs a "name" is a string key and a "symbol" is a symbol key from the symbols page, which is why getOwnPropertyNames skips symbols; Object.keys is older than that convention and does not follow it.
only canonical array indices are ordered first
Seven keys, written in a deliberate scramble. Predict the order Object.keys reports:
Deno.test("only canonical array indices are ordered first", () => {
const order = Object.keys({
b: 1,
"2": 1,
"-1": 1,
"1.5": 1,
"01": 1,
"10": 1,
a: 1,
});
assertEquals(order, ["b", "2", "-1", "1.5", "01", "10", "a"]);
});
Check programs/objects-as-dictionaries.test.ts
running 7 tests from ./programs/objects-as-dictionaries.test.ts
...
only canonical array indices are ordered first ... FAILED (8ms)
ERRORS
only canonical array indices are ordered first => ./programs/objects-as-dictionaries.test.ts:99:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
[
- "2",
- "10",
"b",
+ "2",
"-1",
"1.5",
"01",
+ "10",
"a",
]
FAILURES
only canonical array indices are ordered first => ./programs/objects-as-dictionaries.test.ts:99:11
FAILED | 6 passed | 1 failed (10ms)
error: Test failed
Insertion order was the prediction, and two keys jumped the queue. Key order is specified, not incidental: integer-index keys first in ascending numeric order, then the remaining string keys in insertion order, then symbol keys in insertion order. The precise part, usually described loosely as "numeric keys come first", is which keys count: only a canonical array index does, meaning a string that is exactly what the number would print as. So "2" and "10" sort numerically, while "-1", "1.5", and "01" are ordinary string keys that stay where you put them. "01" is the clearest case: it looks numeric and is not, because String(1) is "1" rather than "01". Correct the prediction to ["2", "10", "b", "-1", "1.5", "01", "a"]:
only canonical array indices are ordered first ... ok (0ms)
Note that "10" follows "2" rather than preceding it, which looks like a string-sorting bug and is the opposite: the one place object ordering behaves better than a naive implementation would.
entries drops symbol keys; fromEntries does not
Deno.test("entries drops symbol keys; fromEntries does not", () => {
const symbolKey = Symbol("symbolKey");
const mixed = { str: 1, [symbolKey]: 2 };
assertEquals(Object.entries(mixed), [["str", 1]]);
const rebuilt = Object.fromEntries([["str", 1], [symbolKey, 2]]);
assertStrictEquals((rebuilt as Record<symbol, unknown>)[symbolKey], 2);
function pick<T extends object>(source: T, ...keys: string[]): Partial<T> {
return Object.fromEntries(
Object.entries(source).filter(([key]) => keys.includes(key)),
) as Partial<T>;
}
assertEquals(pick({ a: 1, b: 2, c: 3 }, "a", "c"), { a: 1, c: 3 });
function invert(source: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(source).map(([key, value]) => [value, key]),
);
}
assertEquals(invert({ i: "textit", b: "textbf" }), {
textit: "i",
textbf: "b",
});
});
entries drops symbol keys; fromEntries does not ... ok (0ms)
The pair are inverses on paper and not quite in practice: Object.entries considers only string keys, Object.fromEntries accepts symbol keys happily, so a round trip through them silently loses symbol-keyed properties. Worth knowing before writing a "copy an object" helper on top of them.
Accept the limitation and the pair is genuinely useful, because it turns any object transformation into an array transformation: filter for pick, map for invert, and the array methods do the work. That is the pattern to reach for rather than building an object with a loop.
a plain object is never empty
{} looks empty. Predict what in finds in it:
Deno.test("a plain object is never empty", () => {
const dict: Record<string, unknown> = {};
assertEquals("toString" in dict, false);
});
Check programs/objects-as-dictionaries.test.ts
running 9 tests from ./programs/objects-as-dictionaries.test.ts
...
a plain object is never empty ... FAILED (8ms)
ERRORS
a plain object is never empty => ./programs/objects-as-dictionaries.test.ts:140:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- true
+ false
FAILURES
a plain object is never empty => ./programs/objects-as-dictionaries.test.ts:140:11
FAILED | 8 passed | 1 failed (10ms)
error: Test failed
"toString" in {} is true. The empty literal inherits everything on Object.prototype, so a dictionary built this way already answers to toString, valueOf, constructor, and several more: a lookup can return a function you never put there, and in can say yes about a key nobody added. Rewrite the test with the full measurement:
Deno.test("a plain object is never empty", () => {
const dict: Record<string, unknown> = {};
assertStrictEquals(typeof dict["toString"], "function");
assert("toString" in dict);
assertFalse(Object.hasOwn(dict, "toString"));
});
a plain object is never empty ... ok (0ms)
This is the real case against plain objects as dictionaries, and Object.hasOwn is the narrow fix: it asks only about own properties, so it gives the answer you meant. Where the inherited properties come from, and how the chain that supplies them works, is the prototypes and inheritance page's subject.
on Deno, __proto__ as a property is inert
The classic third warning about object dictionaries is that you cannot use "__proto__" as a key, because assigning it sets the prototype instead. On Deno, that does not happen, and it is worth measuring because it is a runtime difference rather than a language one:
Deno.test("on Deno, __proto__ as a property is inert", () => {
const viaBracket: Record<string, unknown> = {};
viaBracket["__proto__"] = { injected: true };
assertEquals(Object.keys(viaBracket), ["__proto__"]);
assertStrictEquals(Object.getPrototypeOf(viaBracket), Object.prototype);
const readBack = ({} as Record<string, unknown>)["__proto__"];
assertStrictEquals(readBack, undefined);
const proto = { injected: true };
const literalKey = { __proto__: proto, own: 1 };
assertEquals(Object.keys(literalKey), ["own"]);
assertStrictEquals(Object.getPrototypeOf(literalKey), proto);
const computedKey = { ["__proto__"]: proto };
assertEquals(Object.keys(computedKey), ["__proto__"]);
assertStrictEquals(Object.getPrototypeOf(computedKey), Object.prototype);
});
on Deno, __proto__ as a property is inert ... ok (0ms)
Deno replaces Object.prototype.__proto__ with a stub. The accessor is still there, its getter returns undefined for everything, and its setter defines an ordinary own property, silently. So dict["__proto__"] = 1 is a harmless entry named __proto__, and reading obj.__proto__ anywhere gives undefined rather than a prototype.
One route still works, and it is the one that never went through the accessor: the plain __proto__: key in an object literal sets a prototype, because that case is handled by the literal grammar rather than by any property lookup. Write the identical key with computed brackets and it is just a property again. This is exactly the form the iterator helpers page leaned on in your own iterable can supply the methods, and this step is the proof it promised: on Deno, the literal key is the one __proto__ that still does anything.
Do not carry the conclusion elsewhere. Browsers and Node have not done this, so code meant to run on more than Deno should still assume __proto__ is live, use Object.getPrototypeOf and Object.create, and treat the literal __proto__: key as a separate feature that happens to share a name.
a null prototype inherits nothing to trip over
Deno.test("a null prototype inherits nothing to trip over", () => {
const dict: Record<string, unknown> = Object.create(null);
assertStrictEquals(dict["toString"], undefined);
assertFalse("toString" in dict);
dict["__proto__"] = true;
assertEquals(Object.keys(dict), ["__proto__"]);
assertThrows(
() => String(dict),
TypeError,
"Cannot convert object to primitive value",
);
const htmlToLatex = {
__proto__: null,
i: "textit",
b: "textbf",
u: "underline",
};
assertStrictEquals(Object.getPrototypeOf(htmlToLatex), null);
assertStrictEquals(htmlToLatex.i, "textit");
const grouped = Object.groupBy(["orange", "apple", "banana"], (s) => s[0]);
assertStrictEquals(Object.getPrototypeOf(grouped), null);
assertEquals(Object.keys(grouped), ["o", "a", "b"]);
const match = /(?<initial>o)/.exec("orange");
assertStrictEquals(Object.getPrototypeOf(match!.groups!), null);
});
a null prototype inherits nothing to trip over ... ok (1ms)
Object.create(null) gives an object with no prototype, so it starts genuinely empty: nothing to inherit, nothing for in to find, no accessor to worry about, and the previous step's __proto__ key is just a key. That makes it a decent dictionary and an excellent fixed lookup table, which is what the htmlToLatex literal is, using the one live __proto__: spelling to opt out at birth.
The standard library agrees: Object.groupBy's result and a regular expression's named capture groups both arrive with null prototypes, and so does import.meta, measured on the modules page. Every one of them is a table whose keys come from data, and that is the tell for when to do the same. The cost is in the pinned TypeError: with no inherited toString, String(dict) throws rather than producing [object Object], and for a lookup table that is a fair trade.
grouping is the same choice, made for you
Deno.test("grouping is the same choice, made for you", () => {
type Person = { name: string; country: string };
const people: Person[] = [
{ name: "Louise", country: "France" },
{ name: "Kiyo", country: "Japan" },
{ name: "Léo", country: "France" },
];
const byObject = Object.groupBy(people, (person) => person.country);
const byMap = Map.groupBy(people, (person) => person.country);
assertEquals(byObject["France"]?.map((p) => p.name), ["Louise", "Léo"]);
assertEquals(byMap.get("France")?.map((p) => p.name), ["Louise", "Léo"]);
assertStrictEquals(Object.getPrototypeOf(byObject), null);
assertStrictEquals(byMap.size, 2);
const { France } = Object.groupBy(people, (person) => person.country);
assertStrictEquals(France?.length, 2);
const bySign = Map.groupBy([0, -5, 3, -4], (n) => Math.sign(n));
assertEquals([...bySign.keys()], [0, -1, 1]);
assertEquals(bySign.get(-1), [-5, -4]);
});
grouping is the same choice, made for you ... ok (0ms)
Both take an iterable and a function that computes a key, and both collect the items into buckets; the only difference between them is the one this whole entry is about, which is why they exist as a pair rather than as one function. Two things decide which you want. Destructuring needs an object, so Object.groupBy wins when you know the group names and want them as bindings. Keys that are not strings need a Map, and bySign is the giveaway: its keys are the numbers 0, -1, and 1, and an object would have quietly turned them into "0", "-1", and "1". The input being any iterable makes grouping a natural end for a lazy pipeline from the iterator helpers page.
an index signature promises more than it can keep
Deno.test("an index signature promises more than it can keep", () => {
const prices: Record<string, number> = { apple: 3 };
const missing = prices["pear"];
assertThrows(
() => missing.toFixed(2),
TypeError,
"Cannot read properties of undefined (reading 'toFixed')",
);
});
an index signature promises more than it can keep ... ok (0ms)
Record<string, number> says every string key holds a number. It cannot, and the checker takes it at its word: missing is typed number, holds undefined, and calling a number method on it throws. No any, no cast, no pin needed anywhere in that step. This is the largest hole in a typed dictionary.
noUncheckedIndexedAccess closes it. Add it to deno.json under compilerOptions and re-check the file as it stands right now:
Check programs/objects-as-dictionaries.test.ts
TS2322 [ERROR]: Type 'string | undefined' is not assignable to type 'PropertyKey'.
Type 'undefined' is not assignable to type 'PropertyKey'.
const grouped = Object.groupBy(["orange", "apple", "banana"], (s) => s[0]);
~~~~
at file:///programs/objects-as-dictionaries.test.ts:193:74
The expected type comes from the return type of this signature.
keySelector: (item: T, index: number) => K,
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
at asset:///lib.es2024.object.d.ts:25:22
TS18048 [ERROR]: 'missing' is possibly 'undefined'.
() => missing.toFixed(2),
~~~~~~~
at file:///programs/objects-as-dictionaries.test.ts:232:13
Found 2 errors.
error: Type checking failed.
The hole is now TS18048, 'missing' is possibly 'undefined', because every index read is typed with | undefined. And the flag found a second one this page did not stage: s[0] in the grouping step is also an index read that can miss, on the empty string, and the checker now refuses to use it as a key. That is the flag doing exactly its job on code that looked finished. It is off by default, in Deno as in tsc, and this file keeps it off to match the rest of the series; turn it on for new code, because it is one of the few options that finds real bugs rather than style violations, and the friction it adds is exactly the check you were skipping.
a Map when the keys are data
Deno.test("a Map when the keys are data", () => {
const byKey = new Map<unknown, string>();
const objectKey = { id: 1 };
byKey.set(objectKey, "by identity").set(1, "number").set("1", "string");
assertStrictEquals(byKey.size, 3);
assertStrictEquals(byKey.get(1), "number");
assertStrictEquals(byKey.get("1"), "string");
assertStrictEquals(byKey.get(objectKey), "by identity");
assertEquals([...byKey.values()], ["by identity", "number", "string"]);
});
a Map when the keys are data ... ok (0ms)
Three things an object cannot do, in five lines. 1 and "1" stay distinct because a Map does not convert keys. An object is a usable key, matched by the identity rule from the values and references page. And .size is a property rather than Object.keys(obj).length. Reach for a Map when the keys are data: when they arrive at run time, when they are not strings, when you need a size or insertion order, or when a key could collide with something on Object.prototype.
In practice
- Use a
Mapwhen keys are data. - Use an object for a table written by hand, giving it a null prototype when its keys are looked up rather than known.
- Keep an object when the table must become JSON; a
Mapdoes not surviveJSON.stringifydirectly. - Use
Object.hasOwnrather than a truthiness check so stored falsy values do not disappear. - Enable
noUncheckedIndexedAccessin new projects that index objects by computed keys.