Swift Sets: Unique Values, Set Algebra, and Membership Tests

Published Updated

Think of a set as the guest list on a nightclub door. The bouncer only ever answers one question: is this name on the list? Writing a name down twice changes nothing, and nobody asks who is fourth from the top.

That door list is the mental model for Swift's Set, and it explains every rule that follows. Uniqueness is enforced for you, order is not stored at all, and membership is the operation the whole type is built around. Arrays answer a different question, which is why the two are not interchangeable.

What Is a Set in Swift?

A Set is an unordered collection of unique values of one type. Insert a value that is already there and the set is unchanged. Ask whether a value is present and you get an answer without walking the collection.

Three terms carry the rest of the guide. Uniqueness means at most one copy of any value. Unordered means there is no first element and no index. Hashable is the protocol that makes both of those possible, because a set finds values by hashing them rather than by scanning.

Arrays and dictionaries are the neighbours readers confuse it with. An array keeps order and allows duplicates. A dictionary maps unique keys to values. A set is closest to a dictionary that kept only the keys.

How do you create a set?

A set is written with an array literal, so the type annotation is what tells Swift which collection you meant:

var fruits: Set<String> = ["Apple", "Banana", "Orange"]
var scores: Set = [88, 95, 72]
var empty = Set<String>()

print(fruits.count, scores.count, empty.isEmpty)
// Program output:
// 3 3 true

The second line leans on inference: Set without angle brackets still tells the compiler the collection kind, and Int comes from the values. The third line needs the full type because an empty literal says nothing at all.

Leave the annotation off entirely and you get an Array. That is the single most common surprise with this type, and it comes back in the pitfalls section.

Adding, Removing, and Checking Membership

Four methods cover almost all day-to-day use. Note what insert and remove hand back, because the return values are where the uniqueness rule becomes visible:

var colors: Set = ["Red", "Green"]

let added = colors.insert("Blue")
let repeated = colors.insert("Red")
let removed = colors.remove("Green")
let missing = colors.remove("Purple")

print(added.inserted, repeated.inserted)
print(removed as Any, missing as Any)
print(colors.contains("Blue"))
// Program output:
// true false
// Optional("Green") nil
// true

The insert(_:) method returns a tuple whose inserted flag tells you whether anything changed, so a duplicate is reported rather than silently swallowed. The remove(_:) method returns the element it took out, or nil when the value was never there.

Both return values are discardable, so colors.insert("Blue") on its own compiles without a warning. Reach for the result when "was this already known?" is the actual question you are asking.

Set Algebra: Union, Intersection, and Difference

Sets carry the operations you met in school mathematics, and this is where they earn their place over an array. Each one returns a new set and leaves both operands alone.

The Four Combining Operations

let owned: Set = ["swift", "rust", "go"]
let wanted: Set = ["go", "zig", "swift"]

print(owned.union(wanted).sorted())
print(owned.intersection(wanted).sorted())
print(owned.subtracting(wanted).sorted())
print(owned.symmetricDifference(wanted).sorted())
// Program output:
// ["go", "rust", "swift", "zig"]
// ["go", "swift"]
// ["rust"]
// ["rust", "zig"]

Read them as questions about two lists. Union is everything either side knows about. Intersection is the overlap. Subtracting is what the first side has that the second does not, and symmetric difference is everything that appears on exactly one side.

The sorted() calls are there for the printout only. Without them the order of each result would be unpredictable, which is the whole point of an unordered collection.

Subset, Superset, and Disjoint

Three more methods answer relationship questions and return a Bool:

let core: Set = ["read", "write"]
let admin: Set = ["read", "write", "delete"]
let billing: Set = ["invoice", "refund"]

print(core.isSubset(of: admin))
print(admin.isSuperset(of: core))
print(core.isDisjoint(with: billing))
// Program output:
// true
// true
// true

The pair that gets mixed up is isSubset(of:) and isDisjoint(with:). Subset asks whether every element is contained in the other set; disjoint asks whether no element is. They are opposite ends of the same question, not variations on it.

One edge worth knowing: an empty set is a subset of everything, and it is disjoint from everything. Both answers are true at once, and neither is a bug.

Changing a Set in Place

Each combining operation has a mutating twin that writes back into the receiver instead of returning a new set. The naming is consistent enough to guess:

var permissions: Set = ["read"]
permissions.formUnion(["write", "delete"])
permissions.subtract(["delete"])

print(permissions.sorted())
// Program output:
// ["read", "write"]

The four pairs are union and formUnion, intersection and formIntersection, subtracting and subtract, symmetricDifference and formSymmetricDifference. A variable declared with let only gets the returning forms, which is the compiler doing its job.

Storing Your Own Types in a Set

Any type can go in a set once it conforms to Hashable. For a struct whose stored properties are all Hashable, adding the conformance is usually the whole job, because Swift synthesises the implementation:

struct Tag: Hashable {
    let name: String
    let colour: String
}

var tags: Set = [Tag(name: "swift", colour: "orange")]
let again = tags.insert(Tag(name: "swift", colour: "orange"))

print(tags.count, again.inserted)
// Program output:
// 1 false

The synthesised conformance uses every stored property, so two tags are equal only when both the name and the colour match. Change the colour and you get a second member, which may or may not be what you meant.

When identity should rest on fewer properties, write the conformance yourself and keep the two halves in agreement:

struct User: Hashable {
    let id: Int
    var displayName: String

    static func == (lhs: User, rhs: User) -> Bool {
        lhs.id == rhs.id
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

The contract is one-directional and strict: equal values must produce equal hashes. Hashing a property that == ignores breaks lookups in ways that are painful to debug, so both members here read the same single field.

Synthesis also covers enums, including those with associated values, as long as every associated type is itself Hashable. Classes get neither member synthesized; you write both by hand, and a class also has reference identity, which is a separate idea from the value equality a set uses.

Iterating, Sorting, and Filtering

A set is a Sequence, so the usual tools work. What changes is that you cannot rely on the order of what comes out:

let sizes: Set = [42, 8, 15, 16, 23, 4]

for size in sizes.sorted() {
    print(size, terminator: " ")
}
print("")

let large = sizes.filter { $0 > 15 }
print(type(of: large), large.sorted())
// Program output:
// 4 8 15 16 23 42
// Set<Int> [16, 23, 42]

Two details matter here. Calling sorted() on a set gives you an Array, because ordering is not something a set can hold. Calling filter on a set gives you another Set, which is why the printed type is Set<Int> rather than an array.

Mapping behaves differently again. map returns an array, since the transformed values may not be Hashable and may not be unique. Wrap the result in Set(...) when you want the uniqueness back.

What does constant-time lookup actually mean?

The headline claim is that contains, insert, and remove on a set run in constant time on average, while contains on an array runs in linear time. Both halves need their scope stated.

Constant time here means the average case for a well-behaved Hashable type. A hash function that returns the same value for many elements degrades lookups toward linear time, and a custom hash(into:) that combines only a low-variety property is the usual way that happens.

The array comparison is about searching for a value, not about every operation. Reading array[3] is constant time and a set cannot do it at all. Sets do not win on speed generally; they win on the specific question of membership.

Sizes matter too. Searching an array of eight elements is fast enough that the difference is noise. The gap becomes worth designing around when the collection is large, the lookup happens in a loop, or both.

A Worked Example: Tracking Tags

Here is the type doing a real job. An article has tags, a reader has muted tags, and the app has to decide whether to show the article and what to display:

struct Article {
    let title: String
    let tags: Set<String>
}

let article = Article(
    title: "Sets in Swift",
    tags: ["swift", "collections", "beginner"]
)
let muted: Set = ["beginner", "opinion"]

let isMuted = !article.tags.isDisjoint(with: muted)
let visibleTags = article.tags.subtracting(muted).sorted()

print(isMuted)
print(visibleTags)
// Program output:
// true
// ["collections", "swift"]

Every step is one call because the data shape fits the question. The muting check is a disjointness test, the display list is a subtraction, and the sort happens only at the edge where a human sees the result.

Write the same logic over arrays and you get nested loops, a manual de-duplication pass, and a decision about what to do with a tag that appears twice. The set answered that last question when the data was created.

Pitfalls and Debugging

The Literal That Quietly Became an Array

This is the mistake everyone makes once. Nothing fails at the point of the mistake, and the compiler complains somewhere else entirely:

var ids = [1, 2, 2, 3]
print(ids.count)

var uniqueIds: Set = [1, 2, 2, 3]
print(uniqueIds.count)
// Program output:
// 4
// 3

The first declaration is an array of four, duplicate included. The error usually surfaces later as value of type '[Int]' has no member 'insert' or a complaint about union. When a set method is missing, check the declaration first.

A Mutated Element Lost Inside Its Own Set

Swift's value semantics prevent the classic version of this bug, where mutating a stored object changes its hash and the element becomes unfindable. Inserting a struct copies its stored properties, so nothing you do to the original afterwards can move it, as long as none of those properties is a reference.

The exception is reference-backed state, because the set stores a reference and not the object behind it. Mutating a property that hash(into:) reads leaves the element in the wrong bucket, so contains reports false for something the set still holds.

A struct can hit this too when one of its stored references feeds the hash. Hash only state that cannot change after insertion, whichever kind of type you are storing.

Order You Did Not Ask For

A test that asserts on Array(mySet) or on a printed set will pass locally and fail elsewhere. Swift seeds hashing per process by default, so the order can differ between runs of the same binary.

The fix is to sort before comparing, or to compare sets to sets. Set equality ignores order by definition, so result == [3, 1, 2] is both correct and stable.

Choosing Between Set, Array, and Dictionary

Start from the question the collection has to answer rather than from the data you happen to have:

BehaviourSetArrayDictionary
Keeps insertion orderNoYesNo
Allows duplicatesNoYesUnique keys only
Access by indexNoYesBy key
Membership searchConstant, averageLinearConstant, average
Element requirementHashableAny typeHashable keys
Carries a payloadNoNoYes

Choose a set when duplicates are meaningless and membership is the question: selected identifiers, applied filters, seen items, granted permissions. Choose an array when order carries meaning or a value can legitimately repeat, such as a feed or a list of events.

Choose a dictionary when each unique thing has something attached to it. A set of user identifiers tells you who is present; a dictionary keyed by user identifier tells you what each one is doing.

Sets also make good intermediate values inside a function even when the result is an array. Building a Set to de-duplicate and then calling sorted() is clearer than a manual containment loop, and it does less work.

Frequently Asked Questions

Are Swift sets thread-safe?

No. Value semantics do not make concurrent mutation safe, so give the set a single owner such as an actor or serialise access through one queue. Concurrent reads are safe when nothing mutates the set and its elements are safe to share, which under strict concurrency means the element type is Sendable.

Can a set hold optional values?

Yes. Optional is Hashable whenever its wrapped type is, so Set<Int?> is a legal type and nil is one ordinary member of it. It is rarely what you want, because the nil case usually deserves its own name rather than a slot in a collection.

Why does my set print in a different order each run?

Swift seeds its hashing per process by default, so the iteration order of the same set can differ between launches. Sort the values when you need a stable order, and never write a test that depends on the printed order.

Can you use a set in a SwiftUI State property?

Yes. Set is a value type, so it stores cleanly in a @State property and SwiftUI updates the views that read it when the value changes. Sets of identifiers are a common way to hold multi-selection state in a list.

When would you reach for NSSet instead?

Only when an Objective-C API asks for it, or when you need the reference semantics of NSMutableSet on purpose. In code that stays in Swift there is no reason to prefer it, and you lose the generic element type.

Can a set hold values of different types?

Not directly, because Set is generic over one element type. Set<AnyHashable> will accept mixed values, at the cost of losing the compiler's help. An enum with a case per shape is usually the better model.

Does a set have a capacity you can reserve?

Yes. Set(minimumCapacity:) allocates storage up front, and reserveCapacity(_:) grows an existing set. Both avoid repeated rehashing when you already know roughly how many elements are coming.

Self-Check

  1. What type does var ids = [1, 2, 3] create, and what does it take to make it a set?
  2. What does insert(_:) return, and what does the returned flag tell you?
  3. Which method asks whether two sets share nothing at all?
  4. What does filter on a Set return, and how does sorted() differ?
  5. What must be true of two values whose == says they are equal?
  6. Why can an element become unfindable inside the set that is still holding it?
  7. Given let a: Set = [1, 2] and let b: Set = [2, 3], what is a.symmetricDifference(b)?

Answers

  1. An Array<Int>. Add a type annotation: var ids: Set = [1, 2, 3], or the fuller Set<Int>.
  2. A tuple of (inserted: Bool, memberAfterInsert: Element). The inserted flag is false when an equal value was already present, so nothing changed.
  3. isDisjoint(with:). It returns true when no element appears in both sets, which is the opposite question to isSubset(of:).
  4. filter returns a Set; sorted() returns an Array. Ordering cannot be stored in a set, so sorting has to hand back a different type.
  5. Their hash values must match. Hashing a property that == ignores breaks lookup, so both members must read the same fields.
  6. Its hashed state changed after insertion. That happens whenever the element reaches mutable state through a stored reference, which leaves it in the wrong bucket. A struct of plain values cannot do it, because insertion copies it.
  7. A set containing 1 and 3, in no particular order. Symmetric difference keeps the values that appear on exactly one side, so the shared 2 drops out.

Where to Go Next

The door list holds up all the way down. A set answers membership, refuses duplicates, and never promises order, and every method in the standard library follows from those three commitments.

Pick the collection from the question, add Hashable when your own types need to go in one, and sort only at the point where a person is going to read the result.

Sources

  1. [1]
    Set (Swift Standard Library)
    (developer.apple.com)
  2. [2]
    Collection Types
    (docs.swift.org)
  3. [3]
    Hashable
    (developer.apple.com)