Set

Sets and Reference Equality in TypeScript

The Core Concept

Sets in TypeScript (and JavaScript) use reference equality for objects, not value equality. This means Sets compare objects by their memory reference, not by their content.

What This Means in Practice

const set = new Set();

const obj1 = { id: 1, name: 'Alice' };
const obj2 = { id: 1, name: 'Alice' };  // Same values, different object

set.add(obj1);
set.add(obj2);

console.log(set.size);  // 2 (not 1!)

Even though obj1 and obj2 have identical properties, they're treated as completely different by the Set because they're different objects in memory.

Key Behaviors

✅ Same reference = Same object

const obj = { id: 1 };
set.add(obj);
set.add(obj);  // Adding same reference again
console.log(set.size);  // Still 1 - duplicates prevented

❌ Same values ≠ Same object

set.add({ id: 1 });
set.has({ id: 1 });  // false - different object, even with same values

🔄 Mutations affect the stored object

const user = { name: 'Bob' };
set.add(user);
user.name = 'Robert';  // Mutating the object
// Set still contains the reference, now with updated value

When This Matters

  • Deleting objects: You need the exact reference to delete an object from a Set
  • Checking existence: set.has() only returns true for the exact same reference
  • Preventing duplicates: Only works for the same reference, not equivalent objects

Common Workarounds

If you need value-based equality:

  • Use primitive values (strings, numbers) instead of objects
  • Serialize objects: set.add(JSON.stringify(obj))
  • Use a Map with a unique key: map.set(obj.id, obj)
  • Create a custom wrapper class with value-based equality methods

The Bottom Line

Think of a Set as storing pointers to objects, not the objects' contents. Two houses might be identical in every way, but they're still two different houses at different addresses.

Examples

GT-Sandbox-Snapshot

Code

import { describe, it, expect, beforeEach } from 'vitest';

describe('Objects in Sets - TypeScript', () => {

  describe('Object Reference Equality', () => {
    it('should add and delete objects by reference', () => {
      const set = new Set<{ id: number; name: string }>();

      const obj1 = {id: 1, name: 'Alice'};
      const obj2 = {id: 2, name: 'Bob'};

      // Add objects
      set.add(obj1);
      set.add(obj2);
      expect(set.size).toBe(2);
      expect(set.has(obj1)).toBe(true);
      expect(set.has(obj2)).toBe(true);

      // Delete by reference works
      set.delete(obj1);
      expect(set.size).toBe(1);
      expect(set.has(obj1)).toBe(false);
      expect(set.has(obj2)).toBe(true);
    });

    it('should NOT recognize equivalent objects as the same', () => {
      const set = new Set<{ id: number; name: string }>();

      const obj1 = {id: 1, name: 'Alice'};
      const obj2 = {id: 1, name: 'Alice'}; // Same values, different reference

      set.add(obj1);
      set.add(obj2);

      // Both are added because they're different references
      expect(set.size).toBe(2);
      expect(set.has(obj1)).toBe(true);
      expect(set.has(obj2)).toBe(true);

      // Creating a new object with same values doesn't match
      const obj3 = {id: 1, name: 'Alice'};
      expect(set.has(obj3)).toBe(false);
    });

    it('should prevent duplicate references', () => {
      const set = new Set<{ value: number }>();

      const obj = {value: 42};

      set.add(obj);
      set.add(obj); // Adding same reference again
      set.add(obj); // And again

      // Only one entry because it's the same reference
      expect(set.size).toBe(1);
    });
  });

  describe('Mutating Objects in Sets', () => {
    it('should reflect mutations to objects in the set', () => {
      interface User {
        id: number;
        name: string;
      }

      const set = new Set<User>();
      const user: User = {id: 1, name: 'Alice'};

      set.add(user);
      expect([...set][0].name).toBe('Alice');

      // Mutate the object
      user.name = 'Alice Smith';

      // Set still has the same reference, now with updated value
      expect(set.has(user)).toBe(true);
      expect([...set][0].name).toBe('Alice Smith');
      expect(set.size).toBe(1);
    });
  });


  describe('Working with Complex Objects', () => {
    class Person {
      constructor(public id: number, public name: string) {
      }
    }

    it('should handle class instances by reference', () => {
      const set = new Set<Person>();

      const person1 = new Person(1, 'Alice');
      const person2 = new Person(1, 'Alice'); // Same values, different instance

      set.add(person1);
      set.add(person2);

      // Both are in the set (different instances)
      expect(set.size).toBe(2);

      // Only the exact instance can be found/deleted
      expect(set.has(person1)).toBe(true);
      expect(set.has(new Person(1, 'Alice'))).toBe(false);

      set.delete(person1);
      expect(set.size).toBe(1);
      expect(set.has(person2)).toBe(true);
    });
  });

  describe('Practical Workarounds', () => {
    it('should use Map for value-based lookups', () => {
      // Using Map with a string key for value-based equality
      const map = new Map<string, { id: number; name: string }>();

      const obj1 = {id: 1, name: 'Alice'};
      const obj2 = {id: 1, name: 'Alice'};

      // Use a string key based on object values
      const getKey = (obj: { id: number }) => `id:${obj.id}`;

      map.set(getKey(obj1), obj1);

      // Can find by value using the key
      expect(map.has(getKey(obj2))).toBe(true);
      expect(map.get(getKey(obj2))).toEqual(obj1);
    });

    it('should use JSON.stringify for simple value comparison', () => {
      // Set of serialized objects for value-based uniqueness
      const set = new Set<string>();

      const obj1 = {id: 1, name: 'Alice'};
      const obj2 = {id: 1, name: 'Alice'};
      const obj3 = {id: 2, name: 'Bob'};

      set.add(JSON.stringify(obj1));
      set.add(JSON.stringify(obj2)); // Won't be added (same JSON)
      set.add(JSON.stringify(obj3));

      expect(set.size).toBe(2);
      expect(set.has(JSON.stringify({id: 1, name: 'Alice'}))).toBe(true);
    });
  });

  describe('Array and Nested Object Behavior', () => {
    it('should handle arrays by reference', () => {
      const set = new Set<number[]>();

      const arr1 = [1, 2, 3];
      const arr2 = [1, 2, 3]; // Same values, different array

      set.add(arr1);
      set.add(arr2);

      expect(set.size).toBe(2); // Both arrays are in the set
      expect(set.has(arr1)).toBe(true);
      expect(set.has(arr2)).toBe(true);
      expect(set.has([1, 2, 3])).toBe(false); // New array not recognized
    });

    it('should handle nested objects by reference', () => {
      const set = new Set<{ user: { id: number } }>();

      const nested1 = {user: {id: 1}};
      const nested2 = {user: {id: 1}};

      set.add(nested1);
      set.add(nested2);

      expect(set.size).toBe(2); // Different references

      // Modifying nested object
      nested1.user.id = 999;
      expect(set.has(nested1)).toBe(true);
      expect([...set][0].user.id).toBe(999);
    });
  });
});

Command to reproduce:

gt.sandbox.checkout.commit 22fa526ca9b9c3855fc3 \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./run.sh"

Recorded output of command:


up to date, audited 179 packages in 2s

33 packages are looking for funding
  run `npm fund` for details

2 moderate severity vulnerabilities

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.

> glassthought-sandbox@1.0.0 test
> vitest run


 RUN  v0.34.6 /home/nickolaykondratyev/git_repos/glassthought-sandbox

 ✓ src/main.test.ts  (9 tests) 3ms

 Test Files  1 passed (1)
      Tests  9 passed (9)
   Start at  21:39:46
   Duration  497ms (transform 50ms, setup 0ms, collect 17ms, tests 3ms, environment 0ms, prepare 126ms)