Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 05 / 18 25 MIN

Collections and iteration

Objects, Map and Set

Property access without surprises, the Object helper methods, and the cases where a plain object stops being a usable map and Map, Set or WeakMap takes over.

The tally in the previous lesson walks an array of words and counts them in an object. It works until one of the words is "toString", and then the count for that word comes out as a concatenated function source instead of a number. Nothing threw. The result was wrong, and only for some inputs.

That failure is not a quirk of toString. It is what happens when you use an object as a dictionary: objects come with a prototype full of inherited keys, they accept only strings and symbols as keys, and they have no size. All three of those are fixable, and the language already ships the collections that fix them.

Bracket access is a lookup; dot access is fixed at parse time

Dot access names a property in the source. Bracket access evaluates an expression and uses the result as the key:

const field = 'email';
const user = {
  name: 'Ana',
  [field]: 'ana@example.com',
  ['shipping' + 'Address']: null,
};

console.log(user);                 // { name: 'Ana', email: 'ana@example.com', shippingAddress: null }
console.log(user.name);            // Ana
console.log(user['name']);         // Ana
console.log(user[field]);          // ana@example.com
console.log(Object.keys(user));    // [ 'name', 'email', 'shippingAddress' ]

The square brackets inside the object literal are a computed key: whatever that expression evaluates to becomes the property name. That is how you build an object whose shape depends on data, a request payload, or a column list. Without them, writing { field: 'x' } creates a property literally called field, which is the mistake behind a surprising number of empty request bodies.

For a key that may not exist, config.server and config['server'] are the same read and both give undefined. What you do next is the part that matters, and the tempting test is the wrong one:

const config = { retries: 3 };

console.log(config.server);                  // undefined
console.log('server' in config);             // false
console.log(Object.hasOwn(config, 'retries')); // true
console.log(Object.hasOwn(config, 'toString')); // false
console.log('toString' in config);           // true

in walks the prototype chain, so every object answers true for toString and constructor whether you put them there or not. Object.hasOwn asks whether the key belongs to this object specifically, which is the question you usually meant. If you are still checking truthiness instead, note that a stored 0, '' or false is a present value that reads as absent.

Object.keys, values and entries are the iteration surface

Objects are not iterable. There is no for...of over an object, and no Object.prototype[Symbol.iterator]. What you get instead are three static methods that convert the object into arrays you can iterate:

const scores = { ana: 8, bo: 9, cy: 7 };

console.log(Object.keys(scores));     // [ 'ana', 'bo', 'cy' ]
console.log(Object.values(scores));   // [ 8, 9, 7 ]
console.log(Object.entries(scores));  // [ [ 'ana', 8 ], [ 'bo', 9 ], [ 'cy', 7 ] ]

entries is the one to reach for when you need both halves, because it destructures cleanly and works with Object.fromEntries to rebuild an object of a different shape:

const doubled = Object.fromEntries(
  Object.entries(scores).map(([name, score]) => [name, score * 2]),
);

console.log(doubled);   // { ana: 16, bo: 18, cy: 14 }

Three details about what these three methods return. They cover own enumerable string keys only, so symbol keys are invisible to all of them and a non-enumerable property defined with Object.defineProperty is skipped as well. Object.getOwnPropertyNames shows the hidden string keys, and Object.getOwnPropertySymbols shows the symbols. They also have an order, and it is not the order you wrote the keys: integer-like keys come first in ascending numeric order, then the rest in insertion order, then symbols. An object literal that looks like { 1: 'one', name: 'ana', 0: 'zero' } enumerates as { '0': 'zero', '1': 'one', name: 'ana' }. That rule is stable and specified, and it is still worth avoiding integer-like keys in a plain object, because the ordering you get is not the ordering you typed.

If you want inheritance included, for...in walks the chain, which is usually not what you want:

const proto = { inherited: 'yes' };
const child = Object.create(proto);
child.own = 'yes';

console.log(Object.keys(child));    // [ 'own' ]
for (const key in child) console.log(key);
// own
// inherited

Prefer Object.keys and friends over for...in for this reason. The two loops look interchangeable and are not.

A plain object is a poor map for three concrete reasons

Objects are excellent records: a fixed, known set of fields, written once. They are bad dictionaries, and each reason is visible in code you can run.

The first is the prototype. A tally that starts from {} inherits every key on Object.prototype, and a value that collides with one of them is not counted:

const words = ['salt', 'toString', 'salt', 'toString'];
const tally = {};
for (const word of words) {
  tally[word] = (tally[word] ?? 0) + 1;
}
console.log(tally.salt, tally.toString);
// 2 function toString() { [native code] }1

The nullish operator did its job. tally['toString'] was never null or undefined, because the lookup found the inherited function before it ever reached the object's own properties. The fix is to stop the lookup at the boundary:

const words = ['salt', 'toString', 'salt', 'toString'];

const tally = Object.create(null);
for (const word of words) {
  tally[word] = (tally[word] ?? 0) + 1;
}
console.log(tally.salt, tally.toString);   // 2 2

Object.create(null) produces an object with no prototype, so tally.toString is undefined until you assign it. The object literal form { __proto__: null, salt: 1 } does the same thing, and it is easier to miss when reading. Checking with Object.hasOwn(tally, word) before incrementing also works, at the cost of a test in every iteration.

The second reason is key coercion. An object key is always a string or a symbol. Every other value is converted, and objects all convert to the same string:

const index = {};
const a = { id: 1 };
const b = { id: 2 };

index[a] = 'first';
index[b] = 'second';

console.log(Object.keys(index));   // [ '[object Object]' ]
console.log(index[a], index[b]);   // second second

Two distinct objects, one key. The same thing happens to 1 and '1', which collide silently, and to NaN which becomes the string "NaN". Any identifier that is not already a string is the wrong identifier for an object.

The third is measurement. There is no size on an object, so a count means Object.keys(store).length, which allocates an array of every key to answer a question the runtime could have answered directly. And there is no clear, no insertion-ordered iteration with keys and values together, and no way to add a key without assigning to it. That list is the specification for Map.

JobObjectMap or Set
Keys allowedStrings and symbolsAny value, including objects and NaN
Inherited keysYes, on the prototype chainNo
SizeObject.keys(o).lengthmap.size, kept by the runtime
Iteration orderInteger keys first, then insertion orderInsertion order
JSON round tripWorksSerialises to {} unless you convert first

Map is the dictionary, Set is the membership test

Map keeps its own insertion order, accepts any key type, and answers size in constant time:

const byId = new Map();
byId.set('b', { name: 'Bo' });
byId.set('a', { name: 'Ana' });

console.log(byId.size);            // 2
console.log(byId.get('a'));        // { name: 'Ana' }
console.log(byId.has('zz'));       // false
console.log([...byId.keys()]);     // [ 'b', 'a' ]

const byNumber = new Map([[1, 'one'], ['1', 'string one']]);
console.log(byNumber.size, byNumber.get(1), byNumber.get('1'));
// 2 one string one

Objects as keys behave as references, which is what makes a Map usable for a cache keyed by a request object:

const a = { id: 1 };
const b = { id: 2 };
const cache = new Map([[a, 'first'], [b, 'second']]);

console.log(cache.size, cache.get(a), cache.get(b));   // 2 first second

Set is the same idea for membership alone. Duplicates are dropped by a SameValueZero comparison, so NaN is deduplicated, which is the one place Set beats an array check with indexOf:

const users = new Set(['ana', 'bo', 'ana']);
console.log(users.size, [...users]);   // 2 [ 'ana', 'bo' ]

const refs = new Set();
refs.add({});
refs.add({});
console.log(refs.size);                // 2 - two different objects

That last line is the limit of Set for finding duplicate objects. Two { id: 1 } literals are different values, and Set says so correctly. Deduplicating by a field means a Map keyed by that field, or a sort and a scan.

Both collections are iterable, so they spread, destructure and work with for...of. Map yields [key, value] pairs. Neither survives JSON on its own, which is the one place a plain object is still the right answer:

console.log(JSON.stringify(new Map([['a', 1]])));   // {}
console.log(JSON.stringify(new Set([1, 2, 2])));    // {}
console.log(JSON.stringify([...new Map([['a', 1]])]));   // [["a",1]]

Convert at the boundary. Keep the Map in memory, spread it or call Object.fromEntries(map) when you hand it to JSON.stringify, and rebuild the Map when it comes back.

WeakMap attaches private data to an object without owning it

A WeakMap holds keys weakly, meaning a key that nothing else references becomes collectable and its entry disappears. That makes it the right place for data that belongs to an object but should not be readable from it:

const privateState = new WeakMap();

class Counter {
  constructor() {
    privateState.set(this, { count: 0 });
  }

  increment() {
    const state = privateState.get(this);
    state.count += 1;
    return state.count;
  }
}

const counter = new Counter();
console.log(counter.increment(), counter.increment());   // 1 2
console.log(counter.count);                              // undefined
console.log(Object.keys(counter));                       // []

The state is invisible to anyone holding the counter, and it goes away with the counter, so there is no cleanup step and no leak. The trade-off is that a weak map is not enumerable: there is no size, no keys(), no forEach, and no way to ask what is inside one. That is the point. If you need to list the entries, you have a Map, and if you need the data to be genuinely private from other code in the same realm, a class field prefixed with # is the direct tool. Private per-object data was the original reason WeakMap exists, and the reason it still appears in code written before that private class field syntax was available everywhere.

Lab 25 minutes

You have an array of request log lines already parsed into objects: { path, status, durationMs, actor }. Build a small report with three parts, each using the collection that fits.

First, a count of requests per path, including a path called "constructor". Second, the set of distinct actors. Third, a lookup from actor to the slowest request they made, where slowest means largest durationMs.

Then answer: which of the three results can be serialised with JSON.stringify as-is, and for the others, where does the conversion belong? And if the same actor appears with two different casing ("Ana" and "ana"), whose problem is that, the collection or the code that reads the log?

Show the solution and the reasoning
const entries = [
  { path: '/a', status: 200, durationMs: 12, actor: 'Ana' },
  { path: '/b', status: 500, durationMs: 90, actor: 'ana' },
  { path: '/a', status: 200, durationMs: 8, actor: 'Ana' },
  { path: 'constructor', status: 404, durationMs: 4, actor: 'bo' },
];

const perPath = new Map();
for (const entry of entries) {
  perPath.set(entry.path, (perPath.get(entry.path) ?? 0) + 1);
}

const actors = new Set(entries.map((entry) => entry.actor));

const slowestByActor = new Map();
for (const entry of entries) {
  const current = slowestByActor.get(entry.actor);
  if (current === undefined || entry.durationMs > current.durationMs) {
    slowestByActor.set(entry.actor, entry);
  }
}

console.log(JSON.stringify([...perPath]));
console.log(JSON.stringify([...actors]));
console.log(
  JSON.stringify(
    [...slowestByActor].map(([actor, entry]) => [actor, entry.path, entry.durationMs]),
  ),
);
console.log(JSON.stringify(Object.fromEntries(perPath)));
[["/a",2],["/b",1],["constructor",1]]
["Ana","ana","bo"]
[["Ana","/a",12],["ana","/b",90],["bo","constructor",4]]
{"/a":2,"/b":1,"constructor":1}

The path count is a Map because the keys come from data and one of them is "constructor". Written as an object with tally[path] ?? 0, that path picks up the inherited constructor function and produces a string instead of a number. The Map never has that problem because it has no prototype chain to fall through.

The actor set is a Set because the question is membership and duplicates, not association. Converting it to an array for output is [...actors], and it is O(n) once rather than a nested scan, which is what the equivalent with filter and indexOf would cost.

The slowest-request map is the interesting one, because slowestByActor.get(actor) returns undefined for a new actor and a stored object otherwise. Testing === undefined rather than falsiness matters: an object is always truthy, so either works here, but the same pattern with a stored number would break on a legitimate 0.

Only the path count converts to JSON without losing information. Object.fromEntries(perPath) gives a plain object, and JSON.stringify handles it, because every key was a string already. A Map with object keys cannot make that trip at all, which is the signal that such a map is runtime state rather than transport format. The set needs [...actors] first if you want an array, or Object.fromEntries([...actors].map((a) => [a, true])) if you want an object. The conversion belongs at the point where the data leaves the program, not inside the code that built it.

The casing question is not a collection problem. Set and Map compare keys by value, and "Ana" and "ana" are two different values, so both collections are behaving correctly. Normalising at the boundary where the log is parsed, with actor.trim().toLowerCase() and a rule about which form is canonical, keeps every consumer honest. Pushing the normalisation into the report means the next consumer writes it again, and the two will disagree.

Progress

Mark this lesson done and it stays marked in this browser.