JavaScript Basics: Count, Look Up, and Deduplicate with Map and Set
An array is convenient when order and repeated values matter. Sometimes your real question is different: how many times did this label appear, which record has this ID, or have I already seen this value? JavaScript’s Map and Set make those jobs direct.
A Map associates keys with values. A Set stores unique values. We will use both in three independent examples, then examine why two objects with identical fields still count as different values.
Prepare a scratch directory
These examples were tested on Linux with Node.js v24.19.0. Prerequisites are Node.js on your PATH, Bash, the standard mktemp, rm, and rmdir commands, and a text editor. No external dependencies, network access, environment-variable configuration, or input files are required. Files use .mjs so Node recognizes JavaScript modules without a package file. The small checks use the built-in node:assert/strict module.
Run this in Bash and leave the same shell open until cleanup:
node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1The tested version command printed:
v24.19.0Save each following JavaScript block under its named filename in this directory, using UTF-8 encoding in your editor. Run each file with its separate Bash command.
Count labels with a Map
Save as count-labels.mjs:
import assert from 'node:assert/strict';
function countLabels(labels) {
const counts = new Map();
for (const label of labels) {
counts.set(label, (counts.get(label) ?? 0) + 1);
}
return counts;
}
const counts = countLabels(['tea', 'coffee', 'tea', 'water', 'coffee']);
assert.equal(counts.get('tea'), 2);
assert.equal(counts.has('juice'), false);
assert.equal(countLabels([]).size, 0);
assert.equal(countLabels(['__proto__', '__proto__']).get('__proto__'), 2);
for (const [label, count] of counts) {
console.log(`${label}: ${count}`);
}node count-labels.mjsOutput:
tea: 2
coffee: 2
water: 1get() returns undefined for a missing key. The ?? 0 expression supplies the initial count, and set() stores the updated number. Iteration follows the order in which keys first entered the Map; updating a count does not move its key. Map entries also avoid collisions with object-property names such as __proto__. These operations are defined in the ECMAScript Map specification.
Use .size to count entries, not .length. Use set() and get(), not bracket assignment, to work with Map entries.
Build a lookup and choose a duplicate policy
Save as lookup-products.mjs:
import assert from 'node:assert/strict';
function indexProducts(products) {
const byId = new Map();
for (const product of products) {
if (byId.has(product.id)) {
throw new Error(`Duplicate product ID: ${product.id}`);
}
byId.set(product.id, product);
}
return byId;
}
const products = indexProducts([
{ id: 'p1', name: 'Notebook' },
{ id: 'p2', name: 'Pencil' },
]);
assert.equal(products.get('p2').name, 'Pencil');
assert.equal(products.get('missing'), undefined);
assert.throws(() => indexProducts([{ id: 'p1' }, { id: 'p1' }]),
/Duplicate product ID/);
const optional = new Map([['pending', undefined]]);
assert.equal(optional.has('pending'), true);
assert.equal(optional.has('missing'), false);
console.log(`Found: ${products.get('p2').name}`);
console.log(`Missing: ${products.has('p3')}`);node lookup-products.mjsOutput:
Found: Pencil
Missing: falseThese local records deliberately use string IDs. A plain set() would replace an existing value for the same key. Here, duplicates indicate bad input, so the function rejects them before overwriting anything. Other applications might intentionally keep the first or last record; choose that policy explicitly.
Use has() when stored values can themselves be undefined. A get() result alone cannot distinguish an absent entry from an entry containing undefined. Maps also distinguish numeric 1 from string '1', so normalize external IDs if your application treats those forms as equivalent.
Deduplicate with a Set, respecting identity
Save as deduplicate-values.mjs:
import assert from 'node:assert/strict';
const labels = [...new Set(['red', 'blue', 'red', 'green'])];
assert.deepEqual(labels, ['red', 'blue', 'green']);
assert.equal(new Set([]).size, 0);
assert.equal(new Set([NaN, NaN, 0, -0]).size, 2);
const first = { id: 'p1' };
const second = { id: 'p1' };
const objects = new Set([first, first, second]);
assert.equal(objects.size, 2);
assert.equal(objects.has({ id: 'p1' }), false);
const ids = new Set([first, second].map(product => product.id));
assert.equal(ids.size, 1);
const owners = new Map([[first, 'A']]);
assert.equal(owners.get(second), undefined);
console.log(`Labels: ${labels.join(', ')}`);
console.log(`Objects: ${objects.size}; IDs: ${ids.size}`);node deduplicate-values.mjsOutput:
Labels: red, blue, green
Objects: 2; IDs: 1Spreading a Set into an array preserves first-insertion order. Repeating the same object reference adds nothing, but a separately created object is distinct even when its fields match. Object keys in Maps follow the same identity rule. To deduplicate records by ID, compare their IDs, not the record objects themselves. The ECMAScript Set specification also defines the equality behavior illustrated by repeated NaN and signed zero.
Clean up
Remove only these named files, return to your starting directory, and remove the now-empty scratch directory:
rm -- count-labels.mjs lookup-products.mjs deduplicate-values.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirChoose a Map when each key needs an associated value. Choose a Set when membership is the answer. In both cases, define what makes two items “the same” before building your collection.
Acknowledgements
This post was created with assistance from AI.