How to Use TypeScript Set
Your user keeps clicking the same tag. Your array now has 'javascript' stored four times. You could deduplicate before every save with a .filter() call, or you could store tags in a Set and let it refuse duplicates automatically.
TypeScript's Set stores only unique values and gives you O(1) membership checks, making it the right tool whenever duplicates aren't allowed: selected tags, active session tokens, visited routes, dismissed notifications, or any collection where storing the same value twice is a bug. It covers creating and modifying Sets, iteration, array conversion, Set comparisons, common pitfalls, and WeakSet.
Initializing and Filling a Set
The TypeScript Set constructor creates either an empty Set or one pre-filled from an existing array. When you initialize from an array, duplicates are dropped automatically.
// Empty Set with type annotation
const tagSet = new Set<string>();
// Pre-filled from an array - duplicates removed automatically
const numbers = new Set([1, 2, 3, 4, 4, 5]); // 4 appears once
console.log(numbers.size); // Output: 5
// Set with a union type
const mixedSet = new Set<string | number>(['active', 1, 'inactive', 2]);
Adding, Removing, and Checking Items
Add items with add(), remove them with delete(), and check membership with has(). The add() method silently ignores duplicates, so you never need to check before adding.
const activeUsers = new Set<number>([1, 2, 3]);
// Add a new user
activeUsers.add(4);
console.log(activeUsers.size); // Output: 4
// Remove a user
activeUsers.delete(2);
console.log(activeUsers.size); // Output: 3
// Check if a user is active
console.log(activeUsers.has(3)); // Output: true
// Adding a duplicate has no effect
activeUsers.add(3);
console.log(activeUsers.size); // Output: 3
Going Through a Set
To go through a Set, use the forEach() method or a for...of loop. Sets preserve insertion order, so you'll always iterate in the order elements were added.
const permissions = new Set<string>(['read', 'write', 'delete']);
// Using forEach
permissions.forEach((permission) => {
console.log(permission);
});
// Using for...of - usually the cleaner option
for (const permission of permissions) {
console.log(permission); // read, write, delete
}
// Convert to array if you need index access
const permissionList = [...permissions];
console.log(permissionList); // Output: ['read', 'write', 'delete']
Turning a Set into an Array
TypeScript provides two ways to convert Sets to arrays: the spread operator and Array.from(). Both work equally well with typed Sets, so pick whichever reads more clearly in context.
const visitedRoutes = new Set<string>(['/home', '/dashboard', '/settings']);
// Using spread operator
const routeArray = [...visitedRoutes];
console.log(routeArray); // Output: ['/home', '/dashboard', '/settings']
// Using Array.from()
const routeArray2 = Array.from(visitedRoutes);
console.log(routeArray2); // Output: ['/home', '/dashboard', '/settings']
// Chain array methods after converting
const sortedRoutes = [...visitedRoutes].sort();
console.log(sortedRoutes); // Output: ['/dashboard', '/home', '/settings']
Comparing Sets
To find the union, intersection, or difference of two Sets, spread them into arrays and use filter. These operations return new Sets and leave the originals untouched.
const adminPermissions = new Set<string>(['read', 'write', 'delete', 'admin']);
const editorPermissions = new Set<string>(['read', 'write']);
// Union: all unique permissions across both roles
const allPermissions = new Set([...adminPermissions, ...editorPermissions]);
console.log([...allPermissions]); // ['read', 'write', 'delete', 'admin']
// Intersection: permissions that both roles share
const sharedPermissions = new Set(
[...adminPermissions].filter(p => editorPermissions.has(p))
);
console.log([...sharedPermissions]); // ['read', 'write']
// Difference: permissions only admins have
const adminOnly = new Set(
[...adminPermissions].filter(p => !editorPermissions.has(p))
);
console.log([...adminOnly]); // ['delete', 'admin']
// Subset check: does editor have all admin permissions?
const isSubset = [...editorPermissions].every(p => adminPermissions.has(p));
console.log(isSubset); // Output: true
Clearing a Set
Use clear() to remove all elements. The Set keeps its type annotation and is ready to use again immediately.
const sessionCache = new Set<string>(['user-123', 'user-456', 'user-789']);
sessionCache.clear();
console.log(sessionCache.size); // Output: 0
// Still type-safe after clearing
sessionCache.add('user-100');
TypeScript Set vs Array: Which One Should You Use?
Use a Set when you need unique values or fast membership checks. Use an array when you need index access, sorting, or duplicates.
The performance difference comes from how has() and includes() work under the hood. Array.includes() scans from the beginning of the array, giving it O(n) time complexity. Set.has() uses a hash-based lookup that runs in O(1) average time.
For small collections (under about 100 elements) the difference is negligible. Once you're checking membership across thousands of values, the difference is significant.
// Slow for large lists - O(n) per lookup
const blockedIps: string[] = ['192.168.1.1', '10.0.0.1' /* ...thousands more */];
if (blockedIps.includes(requestIp)) { /* ... */ }
// Fast regardless of size - O(1) per lookup
const blockedIpSet = new Set<string>(['192.168.1.1', '10.0.0.1' /* ...thousands more */]);
if (blockedIpSet.has(requestIp)) { /* ... */ }
| Scenario | Set | Array |
|---|---|---|
| Unique values required | Yes | Manual dedup needed |
| Fast membership check | O(1) | O(n) |
Index access (list[2]) | No | Yes |
| Sorting / slicing | Convert first | Yes |
| Allow duplicates | No | Yes |
| Iteration in insert order | Yes | Yes |
A common pattern is to use a Set for deduplication, then convert to an array for any operations that need it:
const rawTags = ['typescript', 'javascript', 'typescript', 'react'];
const sortedUniqueTags = [...new Set(rawTags)].sort();
console.log(sortedUniqueTags); // ['javascript', 'react', 'typescript']
Working with Complex Types in Sets
TypeScript Sets can store objects and custom types, but they compare by reference, not by content. This is the source of a very common bug.
Sets use the SameValueZero algorithm internally. For primitives (strings, numbers, booleans), this works exactly as you'd expect. For objects, two values are only considered equal if they point to the same object in memory.
type User = { id: number; name: string };
const userSet = new Set<User>();
// Same reference - not added again
const alice = { id: 1, name: 'Alice' };
userSet.add(alice);
userSet.add(alice);
console.log(userSet.size); // Output: 1
// Different references, same content - both get added
userSet.add({ id: 1, name: 'Alice' });
console.log(userSet.size); // Output: 2 - probably not what you wanted
When you need to deduplicate objects, use their unique identifier as the key in a Map<K, V> instead. For database-level deduplication, Convex's filtering gives you more control over how data is queried and stored.
// Use Map<id, object> to enforce uniqueness by a meaningful key
const uniqueUsers = new Map<number, User>();
uniqueUsers.set(1, { id: 1, name: 'Alice' });
uniqueUsers.set(1, { id: 1, name: 'Alice' }); // No duplicate - key already exists
// Or use a Set of primitive IDs alongside your array
const seenIds = new Set<number>();
const deduped = rawUsers.filter(user => {
if (seenIds.has(user.id)) return false;
seenIds.add(user.id);
return true;
});
Where Developers Get Stuck
Object reference equality trips everyone up. As shown above, two objects with identical content are different values to a Set. If your Set of objects keeps growing unexpectedly, this is almost always why.
NaN behaves differently than you might expect. In JavaScript, NaN !== NaN. But in a Set, NaN is treated as equal to itself, so it's stored only once.
const numSet = new Set<number>([NaN, NaN, 1]);
console.log(numSet.size); // Output: 2 - NaN deduped correctly
-0 and +0 are treated as equal. new Set([-0, 0]) has size 1. This rarely causes issues in practice but is worth knowing if you're working with signed zeros.
Don't mutate a Set while iterating it with forEach. Adding elements during a forEach loop will cause the new elements to be visited too, which can lead to an infinite loop.
// Dangerous - newly added elements get iterated
const nums = new Set([1, 2, 3]);
nums.forEach(n => {
if (n === 2) nums.add(4); // 4 will be visited, and so on
});
// Safe - iterate a snapshot with for...of or spread first
for (const n of [...nums]) {
if (n === 2) nums.add(5); // nums is mutated, but we're iterating the snapshot
}
TypeScript WeakSet
A WeakSet is like a Set, but with two constraints: it can only store objects (no primitives), and it holds weak references. If an object in a WeakSet has no other references elsewhere in your code, it gets garbage collected automatically.
type DomNode = { id: string; element: HTMLElement };
const processedNodes = new WeakSet<DomNode>();
function processNode(node: DomNode) {
if (processedNodes.has(node)) return; // Skip if already handled
processedNodes.add(node);
// ... do work
}
The trade-off: WeakSet has no size, no clear(), and is not iterable. You can only call add(), has(), and delete().
Use WeakSet when you need to tag or track objects without preventing garbage collection, for example marking DOM nodes as processed or tracking which objects have been visited in a recursive walk.
Use a regular Set when you need iteration, a size count, or need to store primitive values.
For key-value tracking with the same garbage collection behavior, see typescript-map-type and the related typescript-hashmap guide which covers WeakMap alongside it.
Real-World Applications
Sets are a natural fit for tracking unique values, managing state changes, and filtering duplicates. They pair well with TypeScript's utility types for type-safe data manipulation.
// Tracking which form fields the user has actually touched
const dirtyFields = new Set<keyof UserForm>();
dirtyFields.add('email');
dirtyFields.add('password');
// Only validate fields the user touched
function validateChanges(fields: Set<keyof UserForm>) {
return [...fields].every(field => validators[field]());
}
// Deduplicating tags across an array of content items
interface ContentItem {
title: string;
tags: string[];
}
function getUniqueTags(items: ContentItem[]): string[] {
return [...new Set(items.flatMap(item => item.tags))];
}
While Sets work well for tracking state changes, consider Convex as a better alternative to useState for production applications that need persistent state management.
Using TypeScript Sets Effectively
A few rules to carry with you:
- Reach for Set when uniqueness matters. It's more expressive than filtering an array and performs better at scale.
- Use
has()overincludes()for large collections. The O(1) lookup is the whole point. - Use Map for objects, not Set. Unless you're storing references you control, key by a unique identifier instead.
- Convert to array for sorting, slicing, and index access.
[...mySet]is always a one-liner away. - Set is not a replacement for Map. When you need key-value pairs, reach for
Map<K, V>orRecord<K, V>.
For full-stack applications, Convex's TypeScript integration gives you type-safe data operations at the database layer.