1060 words
5 minutes

JavaScript Set Keeps Duplicate Objects: Deduplicate by ID

2026-10-20
Frontend
JavaScript
/
Node.js
/
Troubleshooting

[...new Set(rows)] keeps separate objects even when their id fields match. To remove duplicate objects by ID, use the ID as a Map key and decide which record should survive. That decision also needs an ordering rule: keeping the last record does not automatically move it to the end.

This article uses ordinary data records with an own id containing a nonempty string or a safe integer. It compares three policies against the same input, rejects unsupported IDs, and checks whether the result shares objects with its source.

A Set of objects does not compare their fields#

MDN’s Set reference explains that object equality depends on identity. Two object literals with matching fields remain different entries. Adding the very same object reference twice produces one entry instead. Neither outcome consults the id property.

The fixture below verifies both cases before applying ID-based deduplication. This distinction matters when an API returns independently constructed records: the duplicate you mean is an application key collision, rather than the same JavaScript object appearing twice.

Choose the collision rule before shortening the implementation into a one-liner:

Policy in the fixtureRetained record for repeated IDPosition of that ID
firstFirst encounteredFirst occurrence
lastLast encounteredFirst occurrence
last-positionLast encounteredLast occurrence

These are encounter-order rules. The last record in an array is not necessarily the newest by timestamp. If freshness is your requirement, compare a validated version or timestamp explicitly rather than assuming response order proves it.

Reproduce all three winner and ordering policies#

Save the following as deduplicate.mjs and run node deduplicate.mjs. The published fixture was executed with Node.js 24.13.0 on macOS during article preparation on October 20, 2026. It requires no dependencies.

import assert from 'node:assert/strict';
function uniqueById(rows, policy = 'first') {
if (!Array.isArray(rows)) throw new TypeError('Expected an array');
if (!['first', 'last', 'last-position'].includes(policy)) {
throw new TypeError('Unknown duplicate policy');
}
const byId = new Map();
for (const row of rows) {
if (row === null || typeof row !== 'object' || Array.isArray(row)) {
throw new TypeError('Expected a data record');
}
const id = row.id;
if (!Object.hasOwn(row, 'id') ||
!(typeof id === 'string' && id.length > 0 ||
typeof id === 'number' && Number.isSafeInteger(id))) {
throw new TypeError('Expected an own nonempty string or safe-integer id');
}
if (policy === 'first' && byId.has(id)) continue;
if (policy === 'last-position') byId.delete(id);
byId.set(id, row);
}
return [...byId.values()];
}
const rows = [
{ id: 'a', version: 'a-old' },
{ id: 'b', version: 'b-only' },
{ id: 'a', version: 'a-new' },
];
assert.equal(new Set(rows).size, 3);
assert.equal(new Set([rows[0], rows[0]]).size, 1);
console.log('Set: three separate objects remain; repeated reference collapses');
for (const [policy, expected] of [
['first', ['a-old', 'b-only']],
['last', ['a-new', 'b-only']],
['last-position', ['b-only', 'a-new']],
]) {
const result = uniqueById(rows, policy);
const versions = result.map(row => row.version);
assert.deepEqual(versions, expected);
assert.equal(rows.length, 3);
assert.equal(rows[0].version, 'a-old');
console.log(`${policy}: ${versions.join(', ')}`);
}
const mixed = uniqueById([
{ id: 1 }, { id: '1' }, { id: 0 }, { id: -0 },
]);
assert.deepEqual(mixed.map(row => row.id), [1, '1', 0]);
console.log('keys: number 1 differs from string 1; zero and negative zero collide');
for (const bad of [{}, { id: undefined }, { id: null }, { id: '' },
{ id: NaN }, { id: Infinity }, { id: {} }, { id: 1.5 }]) {
assert.throws(() => uniqueById([bad]), TypeError);
}
assert.throws(() => uniqueById(new Array(1)), TypeError);
assert.deepEqual(uniqueById([]), []);
console.log('validation: invalid IDs and sparse input rejected; empty array accepted');
const retained = uniqueById(rows);
assert.notEqual(retained, rows);
assert.equal(retained[0], rows[0]);
retained[0].version = 'changed';
assert.equal(rows[0].version, 'changed');
console.log('references: new array, original record objects');
console.log('all assertions passed');

Expected output:

Set: three separate objects remain; repeated reference collapses
first: a-old, b-only
last: a-new, b-only
last-position: b-only, a-new
keys: number 1 differs from string 1; zero and negative zero collide
validation: invalid IDs and sparse input rejected; empty array accepted
references: new array, original record objects
all assertions passed

For the input sequence a-old, b-only, a-new, both last-wins policies keep a-new. Their different output order is intentional. The assertions check the exact sequence, rather than merely confirming that the output contains two records.

The implementation uses one local Map for each call. In first mode, an existing key skips replacement. In last mode, replacement keeps that key’s position. In last-position mode, deleting before setting gives the replacement a new position. The ECMAScript Map algorithms distinguish updating an existing entry from appending a new entry; the fixture exercises both paths.

Validate IDs before treating them as keys#

The helper rejects a missing, undefined, null, empty-string, object, fractional, infinite, or NaN ID. This is an application policy implemented by the guard, not a restriction imposed by Map. Without a missing-ID check, multiple records whose row.id is undefined can collapse into one entry, concealing malformed input.

A string ID and a numeric ID remain separate here: 1 differs from '1'. Zero and negative zero share a key, as the mixed-ID assertion demonstrates. Map’s documented key equality explains this boundary.

Do not add String(row.id) simply to make records collapse. First decide whether numeric and textual identifiers denote the same entity in your data contract. This helper does not trim strings, normalize case, or reject whitespace-only strings; add those checks if your identifier format requires them. It accepts zero, so a truthiness check such as if (!row.id) would enforce a different policy.

A sparse array also fails validation: the loop encounters an undefined entry instead of a data record. An empty dense array returns an empty result. The code assumes ordinary records whose field access does not execute application behavior; it does not sanitize getters or proxies. Parse external payloads into your accepted schema before applying the helper.

Deduplication does not create independent records#

The result array is new, but the selected records are the original objects. The final assertion deliberately mutates a retained record and observes the change through the source array. This is separate from the earlier checks that deduplication itself leaves the original length and first record intact.

If a caller must edit the output without changing the source, define a copying step for the retained data. A shallow object spread still shares nested objects; see the nested object spread and reference checks. Do not infer deep independence from the fact that the array itself is new.

For an ID-based result, verify three things at the call site: the chosen record for each collision, the required order, and whether shared record references are acceptable. The fixture covers those decisions for string and safe-integer IDs in Node.js. It does not benchmark large lists, test browser or framework state behavior, resolve composite keys, or define a deep-equality algorithm.

References:

JavaScript Set Keeps Duplicate Objects: Deduplicate by ID
https://laplusda.com/en/posts/javascript-set-duplicate-objects-by-id/
Author
Zero
Published at
2026-10-20
License
CC BY-NC-SA 4.0