Implementing Custom Swift Collections: Protocols, Performance, and Pitfalls

Why Build a Custom Collection in Swift?

A custom Swift collection is any type that conforms to the Collection protocol, giving it first-class access to iteration, subscripting, and dozens of standard library algorithms. For professional developers working on iOS, macOS, watchOS, or tvOS apps, rolling your own collection isn’t about reinventing Array—it’s about modeling domain-specific data structures that feel native to the language. Adjacent concepts include Sequence, BidirectionalCollection, RandomAccessCollection, and MutableCollection, each adding guarantees about traversal speed and mutability. When you wrap a specialized tree, a ring buffer, or a lazy paginated data source behind a Collection interface, you unlock predictable for-loop behavior, map/filter support, and safe index management without forcing callers to understand the internals. This matters because production apps often outgrow the built-in Array, Set, and Dictionary—especially when dealing with large data sets, real-time audio buffers, or custom caching layers where performance and memory layout are non-negotiable.

Understanding the Collection Protocol Hierarchy

Before writing a single line of implementation code, you need to map the protocol hierarchy. At the base sits Sequence, which provides the ability to iterate over elements one at a time. Collection inherits from Sequence and adds the concept of a position—an Index—that can be stored and reused. This is the dividing line: if your type only needs to vend elements once, stick with Sequence. If you need random access, slicing, or repeated traversal, you need Collection. Above that, BidirectionalCollection adds the ability to move backward from an index, and RandomAccessCollection promises O(1) index advancement and distance calculation. MutableCollection layers on write access, and RangeReplaceableCollection gives you insertion and removal. Choosing the right level avoids unnecessary protocol requirements and keeps your implementation honest about its performance characteristics.

Sequence vs. Collection: The Index Distinction

A common misstep is implementing Collection when a simple Sequence would suffice. Sequence only requires a single makeIterator() method. Collection demands at least startIndex, endIndex, index(after:), and a subscript that returns an Element. The index type itself must be Comparable. If your data source is inherently forward-only—like a stream of incoming network packets—forcing it into a Collection shape will lead to awkward index management and misleading performance expectations. I’ve seen teams create a “collection” that regenerates its entire backing store on every index advance, which technically conforms but destroys any sense of predictable complexity. The standard library’s documentation on Collection is the authoritative reference for these guarantees.

Step-by-Step: A SortedBag Collection

Let’s build a concrete example: a SortedBag that stores comparable elements and keeps them sorted, allowing duplicates. This isn’t a Set because duplicates are permitted, and it isn’t an Array because insertion order is irrelevant—only sort order matters. The backing store will be a simple sorted array, but the collection interface hides that detail. We’ll implement BidirectionalCollection since we can efficiently traverse forward and backward, but we won’t claim RandomAccessCollection because our index type will be a plain Int offset, which already satisfies random-access requirements. The exercise demonstrates how to design a custom index, manage value semantics, and handle the subtle interaction between Comparable elements and collection conformance.

Defining the SortedBag Type

Start with a generic struct constrained to Comparable elements. The internal storage is a private sorted array. We’ll expose a count property and an initializer that accepts a sequence and sorts it. The key design decision is the index type: we could use Int directly, but wrapping it in a custom Index struct clarifies intent and prevents accidental misuse. Here’s the skeleton:

public struct SortedBag<Element: Comparable> {
  private var storage: [Element]
  
  public init<S: Sequence>(_ sequence: S) where S.Element == Element {
    self.storage = sequence.sorted()
  }
  
  public var count: Int { storage.count }
  
  public struct Index: Comparable {
    fileprivate let offset: Int
    public static func < (lhs: Index, rhs: Index) -> Bool {
      lhs.offset < rhs.offset
    }
  }
}

The Index type is nested inside SortedBag and only exposes an internal offset. Conforming to Comparable is required for any Collection index. By keeping the initializer fileprivate, we prevent external code from manufacturing arbitrary indices, which would break the collection’s invariants.

Conforming to Collection

The minimal Collection conformance requires startIndex, endIndex, index(after:), and a read-only subscript. Since we also want backward traversal, we’ll add index(before:) and declare conformance to BidirectionalCollection. The implementation is straightforward because we can delegate to the array’s integer indices:

extension SortedBag: BidirectionalCollection {
  public var startIndex: Index { Index(offset: storage.startIndex) }
  public var endIndex: Index   { Index(offset: storage.endIndex) }
  
  public func index(after i: Index) -> Index {
    Index(offset: storage.index(after: i.offset))
  }
  
  public func index(before i: Index) -> Index {
    Index(offset: storage.index(before: i.offset))
  }
  
  public subscript(position: Index) -> Element {
    storage[position.offset]
  }
}

With just these methods, SortedBag gains for-loop iteration, map, filter, reduce, first(where:), and all the other free functions the standard library provides to any Collection. The compiler synthesizes the Index type’s Comparable conformance automatically when all stored properties are Comparable, but we provided an explicit implementation for clarity.

Adding MutableCollection Conformance

If we want callers to modify elements in place, we need MutableCollection. This requires a writable subscript. However, there’s a catch: if we allow arbitrary element assignment, we could break the sorted invariant. A caller could write bag[someIndex] = smallerElement and corrupt the order. The safe approach is to provide a setter that removes the old element and inserts the new one in the correct sorted position, but that changes the collection’s structure—which a subscript setter shouldn’t do. A more honest design is to keep SortedBag as a read-only collection and provide a separate insert(_:) method that maintains the sort order. This is a classic tradeoff: conforming to MutableCollection would be misleading because the performance characteristics of the subscript setter would be O(n) rather than O(1). The Swift standard library’s MutableCollection documentation explicitly notes that the subscript setter should have O(1) complexity. Respecting these semantic expectations is part of writing a well-behaved custom collection.

Performance Considerations and Index Validation

Custom collections often fail in production because developers overlook index invalidation. In SortedBag, if we add an insert(_:) method that modifies the underlying array, any previously stored indices might point to different elements or become out-of-bounds. The standard library’s Array handles this by documenting that indices are invalidated after mutation; your custom collection should do the same. A safer pattern is to make the collection a value type with copy-on-write semantics, so that mutations create a new copy and old indices remain valid for the original instance. This is how Swift’s built-in collections work, and it’s worth mimicking for consistency.

Another performance trap is the index(after:) implementation. If your index type stores a reference to the collection and traverses a linked structure, each call might be O(n) rather than O(1). Always document the complexity of your index operations. For a sorted array backing, index(after:) is O(1), but if you later change the backing store to a balanced binary tree, that complexity changes. The Swift standard library source is an excellent reference for how Apple documents these guarantees.

Conforming to ExpressibleByArrayLiteral

To make SortedBag feel truly native, conform it to ExpressibleByArrayLiteral. This lets users write let bag: SortedBag = [3, 1, 2] and get a sorted bag containing [1, 2, 3]. The implementation is trivial but significantly improves the developer experience:

extension SortedBag: ExpressibleByArrayLiteral {
  public init(arrayLiteral elements: Element...) {
    self.storage = elements.sorted()
  }
}

This is one of those small touches that separates a production-quality API from a quick-and-dirty utility. When your custom collection feels like a built-in type, other developers on the team are far more likely to adopt it and understand its behavior.

Common Pitfalls When Implementing Custom Collections

Over the years, I’ve seen—and made—several mistakes that turn a promising custom collection into a source of subtle bugs. Here are the ones worth avoiding from the start.

Incorrect Index Comparison

Indices from one collection instance must never be used with another. If your index type stores a reference to the collection, you can add a precondition that checks the collection’s identity. If your index is just an integer offset, you’re relying on the caller to be careful—and they won’t be. Consider making the index type opaque or using a unique identifier per collection instance to catch cross-collection index usage at runtime.

Ignoring Value Semantics

Swift collections are value types with copy-on-write behavior. If your custom collection uses a reference type as its backing store, you must implement copy-on-write manually to avoid shared mutable state. The standard pattern is to use isKnownUniquelyReferenced(_:) inside mutating methods. Skipping this step leads to bugs where mutating one instance unexpectedly changes another—a nightmare to debug in large codebases.

Over-Conforming to Protocols

It’s tempting to conform to RandomAccessCollection just because your index is an Int. But RandomAccessCollection promises O(1) index(_:offsetBy:) and O(1) distance measurement. If your backing store is a linked list, you can’t fulfill that promise. Be conservative: conform only to the protocols whose performance characteristics you can guarantee. Callers will make algorithmic assumptions based on those conformances.

Testing Your Custom Collection

The Swift standard library includes a checkCollection function in its test suite that verifies protocol conformance correctness. While not publicly exposed, you can write your own validation suite. At minimum, test index traversal, slicing, and interaction with standard library algorithms like map, filter, and reduce. Verify that startIndex and endIndex behave correctly for empty collections. Test that indices from one instance don’t work with another. If you implement MutableCollection, verify that mutations don’t corrupt the collection’s invariants.

Real-World Use Cases

Custom collections aren’t just an academic exercise. In production apps, I’ve used them for:

  • Ordered dictionaries that maintain insertion order while providing O(1) key lookup—useful for JSON serialization where key order matters.
  • Ring buffers for real-time audio processing, where a fixed-size circular collection avoids memory allocation in the hot path.
  • Lazy paginated data sources that fetch items from a server on demand, presenting a smooth infinite-scroll interface to the UI layer.
  • Interval trees for calendar apps that need to quickly find overlapping events.

Each of these benefits from a Collection interface because it lets the rest of the app use familiar patterns without knowing the underlying complexity.

FAQ

When should I use a custom collection instead of just wrapping an Array?

Wrap an Array when you need to enforce invariants that Array alone can’t guarantee—like sorted order, uniqueness, or a fixed capacity. If your type is just a bag of data with no behavioral constraints, a typealias or a simple struct with an Array property is often enough. The moment you find yourself writing the same validation logic in multiple places, a custom collection pays off.

Why does my custom collection crash when I use it with prefix(while:)?

This usually means your endIndex isn’t properly reachable from startIndex via repeated calls to index(after:). The standard library algorithms assume that advancing from startIndex will eventually hit endIndex. If your index logic has an off-by-one error or an infinite loop, prefix(while:) will walk right past the end and crash. Double-check your index(after:) implementation and ensure it returns endIndex when called on the last valid index.

Can I make my custom collection work with SwiftUI’s ForEach?

Yes, but ForEach requires RandomAccessCollection and elements that are Identifiable or an explicit id key path. If your collection conforms to RandomAccessCollection and your elements are Identifiable, it works out of the box. If you can’t guarantee O(1) random access, consider providing a separate Array snapshot for the UI layer rather than bending your collection’s semantics.

How do I handle copy-on-write for a reference-type backing store?

Use a private class to hold the mutable state, and store it in your struct. Before any mutating operation, check isKnownUniquelyReferenced(&_storage). If it returns false, create a copy of the storage object. This ensures that value semantics are preserved without incurring the cost of copying on every mutation. The Swift standard library’s Array uses this exact technique.

Close-up of Swift code on a MacBook screen showing collection protocol conformance

Next Steps for Your Codebase

Once you’ve built a custom collection, the natural next step is to write a custom Sequence that vends elements lazily—perhaps a LazyPaginatedSequence that fetches data from your backend as the user scrolls. This pairs well with the collection you’ve just built and deepens your team’s understanding of Swift’s protocol-oriented design. If you found this walkthrough useful, you might also explore how AsyncSequence extends these concepts to asynchronous data streams, a topic I’ll cover in a future column.

Developer writing Swift code in Xcode on a desk with a notebook and coffee

The real power of custom collections isn’t in the protocol conformance itself—it’s in the way they let you express domain logic directly in the type system. When a SortedBag or a RingBuffer appears in a function signature, the intent is unmistakable. That clarity reduces bugs and makes code reviews faster. Start with a small, focused collection that solves a specific problem in your app, and iterate from there.

Swift programming book open next to an iPad showing Xcode documentation