Building Custom Swift Collections: A Practical Guide to Sequence and Collection Protocols
Swift’s standard library hands you Array, Set, and Dictionary—solid workhorses that cover most day-to-day needs. But the moment you reach for a ring buffer, a sorted bag, or a lazy evaluation pipeline, you’re staring down the barrel of a custom collection. A custom Swift collection is any type that conforms to the Collection protocol, inherits from Sequence, and maybe picks up MutableCollection or RandomAccessCollection along the way. This article walks through the protocol hierarchy, the methods you can’t skip, and the performance tradeoffs that separate a collection that merely works from one that feels native.
Understanding the Swift Collection Protocol Hierarchy
Before you type anything, get the layering straight. The hierarchy starts with Sequence—the bare minimum for stepping through elements one at a time. Conform to Sequence by writing a makeIterator() method, and you instantly unlock map, filter, reduce, and a pile of other higher-order functions. Collection builds on that by introducing a position—an Index—and guarantees you can walk the elements multiple times without consuming them. That’s the baseline for using a type in a for–in loop with subscript access.
Further down the line, MutableCollection lets you set elements at a given index, and RandomAccessCollection promises O(1) index advancement. The latter is what makes binary search and other distance-measuring algorithms efficient. Knowing this hierarchy helps you pick the right protocol for your data structure and avoid accidentally promising performance you can’t deliver.
Step 1: Conforming to Sequence
Every collection starts life as a sequence. The one thing you must provide is an iterator. For a custom ring buffer—a fixed-size structure that overwrites old elements when full—you might begin with a plain Sequence conformance to test the iteration logic before wrestling with index management.
struct RingBuffer<Element> {
private var storage: [Element?]
private var writeIndex = 0
private var count = 0
init(capacity: Int) {
storage = Array(repeating: nil, count: capacity)
}
mutating func append(_ element: Element) {
storage[writeIndex % storage.count] = element
writeIndex += 1
count = min(count + 1, storage.count)
}
}
extension RingBuffer: Sequence {
func makeIterator() -> AnyIterator<Element> {
var index = 0
var yielded = 0
return AnyIterator {
guard yielded < self.count else { return nil }
let start = (self.writeIndex - self.count) % self.storage.count
let element = self.storage[(start + index) % self.storage.count]
index += 1
yielded += 1
return element
}
}
}
This iterator wraps the circular logic so elements come out in insertion order. But a Sequence alone won’t give you subscripting or repeated traversal. For that, you need the Collection protocol.
Conforming to Collection
To turn RingBuffer into a real collection, you define a startIndex, an endIndex, and a subscript that returns elements for a given index. The index type must be Comparable, and you have to supply an index(after:) method. A common trick is to wrap an integer offset in a custom type—keeps the implementation readable while hiding the circular math.
extension RingBuffer: Collection {
struct Index: Comparable {
let offset: Int
static func < (lhs: Index, rhs: Index) -> Bool {
return lhs.offset < rhs.offset
}
}
var startIndex: Index {
return Index(offset: count == storage.count ? writeIndex : 0)
}
var endIndex: Index {
return Index(offset: writeIndex)
}
subscript(position: Index) -> Element {
return storage[position.offset % storage.count]
}
func index(after i: Index) -> Index {
return Index(offset: i.offset + 1)
}
}
Notice the index isn’t a plain Int. A custom Index type stops you from accidentally passing integer literals and keeps the offset logic tucked away. The startIndex calculation handles the wrap-around: when the buffer is full, the oldest element sits at writeIndex; otherwise, it’s at zero.
Adding MutableCollection and BidirectionalCollection
If you want in-place mutation, conform to MutableCollection. That means adding a setter to the subscript. For a ring buffer, you’ll probably also want BidirectionalCollection for reverse iteration—trivial once you have a custom index type.
extension RingBuffer: MutableCollection {
subscript(position: Index) -> Element {
get {
return storage[position.offset % storage.count]!
}
set {
storage[position.offset % storage.count] = newValue
}
}
}
extension RingBuffer: BidirectionalCollection {
func index(before i: Index) -> Index {
return Index(offset: i.offset - 1)
}
}
With these additions, your ring buffer supports reversed() and can slide into any algorithm that needs bidirectional traversal. The force-unwrap in the subscript is safe because the index always lands inside the valid range of stored elements.
Conforming to RandomAccessCollection
For a ring buffer, random access comes naturally—the backing store is an array. Conforming to RandomAccessCollection tells the standard library that index arithmetic is O(1). That unlocks performance optimizations in sort, binarySearch, and friends. The only extra work is implementing index(_:offsetBy:) and distance(from:to:).
extension RingBuffer: RandomAccessCollection {
func index(_ i: Index, offsetBy distance: Int) -> Index {
return Index(offset: i.offset + distance)
}
func distance(from start: Index, to end: Index) -> Int {
return end.offset - start.offset
}
}
Now your ring buffer behaves like any other random-access collection in Swift. You can call count, isEmpty, and even feed it to generic algorithms that demand RandomAccessCollection.
Conforming to RangeReplaceableCollection
If you want your custom collection to support append(contentsOf:) or removeSubrange(_:), adopt RangeReplaceableCollection. This protocol asks for an initializer that creates an empty collection and a replaceSubrange(_:with:) method. For a ring buffer, range replacement gets messy because of the circular storage, but it’s doable with careful index mapping.
extension RingBuffer: RangeReplaceableCollection {
init() {
self.init(capacity: 10) // default capacity
}
mutating func replaceSubrange<C: Collection>(_ subrange: Range<Index>, with newElements: C) where C.Element == Element {
// Simplified: remove elements in subrange, then insert new elements
let removeCount = distance(from: subrange.lowerBound, to: subrange.upperBound)
// ... implementation details for circular buffer manipulation
}
}
Adopting RangeReplaceableCollection hands you a ton of free methods—+ operator support, removeAll(keepingCapacity:), reserveCapacity(_:), and more. Just make sure your implementation respects the expected performance characteristics; otherwise, you’ll surprise anyone who leans on standard library semantics.
Practical Example: A SortedCollection
Let’s apply these protocols to something more immediately useful: a SortedCollection that keeps elements in order on insertion. Handy for a leaderboard, a priority list, or any dataset that must stay sorted. We’ll use a plain array as the backing store and binary search for insertion.
struct SortedCollection<Element: Comparable> {
private var storage: [Element] = []
mutating func insert(_ element: Element) {
let index = storage.firstIndex(where: { $0 >= element }) ?? storage.endIndex
storage.insert(element, at: index)
}
}
extension SortedCollection: Collection {
typealias Index = Int
var startIndex: Int { storage.startIndex }
var endIndex: Int { storage.endIndex }
subscript(position: Int) -> Element { storage[position] }
func index(after i: Int) -> Int { storage.index(after: i) }
}
Because the backing store is an array, we can piggyback on its Collection conformance. But we have to be careful: if we expose MutableCollection or RangeReplaceableCollection directly, someone could break the sorted invariant by assigning an out-of-order element. Instead, we provide a custom insert(_:) method that preserves the sort order and only expose read-only collection access.
This pattern—wrapping a standard collection and exposing a restricted interface—shows up all over Swift. It leans on the existing implementation while enforcing domain-specific rules. For a sorted collection, you might also add methods like contains that use binary search for O(log n) performance, rather than the default O(n) linear search from Sequence.
Performance Considerations and Index Validation
When you build a custom collection, performance isn’t an afterthought—it’s part of the contract. The Swift standard library documents expected complexity for each protocol requirement. Collection demands that startIndex and endIndex be O(1), and subscript access should be O(1) for RandomAccessCollection. If your implementation drifts from those guarantees, you risk breaking algorithms that assume them.
Index validation is another area that bites. Swift’s collections use a // FIXME approach in debug builds to trap on invalid index usage, but in release builds, the behavior is undefined. Mirror that by dropping precondition checks into your subscript and index-advancement methods. It catches bugs early without punishing release performance.
For example, in the ring buffer’s subscript, you could add:
precondition(position.offset >= startIndex.offset && position.offset < endIndex.offset, "Index out of bounds")
This lines up with Swift’s philosophy: safety during development, speed in production.
Lazy Collections and Wrappers
Swift’s standard library includes lazy collections that defer computation until you actually access an element. You can build your own lazy wrappers by conforming to LazyCollectionProtocol. Picture a LazyMapCollection that applies a transformation on the fly without storing intermediate results. That’s a big deal when you’re chaining operations on large datasets—it sidesteps creating temporary arrays.
To create a lazy wrapper, store the base collection and the transformation closure, then implement the Collection protocol so subscript access applies the closure. The index type is usually the same as the base collection’s index, which keeps traversal simple. This pattern is used heavily in Swift’s own LazyMapSequence and LazyFilterCollection.
Integrating with Swift Algorithms
Apple’s Swift Algorithms package extends the standard library with powerful sequence and collection algorithms. When you build a custom collection that conforms to the standard protocols, you automatically gain access to these algorithms. For example, a SortedCollection can be chunked, cycled, or partitioned using the package’s methods. This interoperability is a big reason to invest in proper protocol conformance.
Testing Your Custom Collection
Swift’s standard library includes a hidden testing utility called checkCollection (found in the StdlibCollectionUnittest module, though it’s not officially public). For your own tests, write a comprehensive suite that verifies index traversal, subscript access, and edge cases like empty collections. A good practice is to test your collection against an Array with the same elements, making sure iteration order, count, and subscript values match.
For the ring buffer, test scenarios should include: filling the buffer to capacity, overwriting old elements, iterating forward and backward, and verifying that startIndex correctly shifts when the buffer wraps. These tests catch the off-by-one errors that love to hide in circular buffer logic.
When to Build a Custom Collection
Not every data structure needs to be a full Collection. If you only need iteration, conforming to Sequence is enough—and simpler. If you need repeated traversal but not subscript access, Collection is the right call. Save RandomAccessCollection for structures where index arithmetic is truly constant-time. Over-conforming can mislead users and lock you into performance promises you can’t keep.
Consider a tree structure: you could conform to Collection by flattening the tree into an array, but index advancement would be O(log n) or worse, violating the expected O(1) for Collection. In those cases, it’s better to provide a custom traversal method than a misleading protocol conformance.
FAQ
What is the difference between Sequence and Collection in Swift?
Sequence provides a single-pass iterator and doesn’t guarantee that elements can be traversed multiple times. Collection extends Sequence with an Index type, allowing multi-pass traversal, subscript access, and a known count. Use Sequence for one-off streams like network data; use Collection for stored data structures.
Do I need to implement all protocol requirements manually?
No. Many protocols provide default implementations. For example, Collection gives you count, isEmpty, and first for free once you provide startIndex, endIndex, and index(after:). You can override these defaults if you have a more efficient implementation.
How do I make my custom collection work with for-in loops?
Any type that conforms to Sequence works with for–in loops. Since Collection inherits from Sequence, conforming to Collection automatically enables this. You don’t need to do anything extra beyond providing the required makeIterator() method, which has a default implementation based on your index.
Can I use a custom collection with SwiftUI’s List or ForEach?
Yes, as long as your collection conforms to RandomAccessCollection and its elements are Identifiable or you provide a key path. ForEach relies on stable indices, so your collection must guarantee that indices remain valid across updates. Be cautious with mutable collections that might invalidate indices.


