How to Implement Custom Swift Collections
Swift’s standard library gives you Array, Set, Dictionary, and their lazy or slice variants. They’re solid. But production apps have a way of outgrowing them. Maybe you need a ring buffer for a real‑time audio pipeline, an ordered set that remembers insertion order while blocking duplicates, or a sparse grid that doesn’t burn memory on empty cells. That’s when you write a custom collection. Conform to Collection, BidirectionalCollection, or RandomAccessCollection and your type instantly gets dozens of free algorithms—map, filter, reduce, prefix(while:)—and it feels native to anyone reading your code. The real work is supplying an Index type, a startIndex, an endIndex, and a subscript that returns elements in O(1). Nail those, and the rest of the protocol practically writes itself.
I’m Yuki Tanaka. I’ve lost more late nights than I care to count chasing down collection conformances. The compiler errors can be cryptic, and the docs sometimes read like a math proof. But once the index model clicks, custom collections become one of the most satisfying corners of Swift. Let’s walk through a real implementation, poke at the tradeoffs, and build something you can actually ship.
Why the Standard Collections Aren’t Always Enough
Apple’s built‑in collections are general‑purpose workhorses. Array gives you contiguous storage and O(1) random access, but inserting at the front costs O(n). Set and Dictionary are hash‑based, so they don’t preserve order. ContiguousArray and ArraySlice help with performance, but they don’t change the fundamental data structures. When your app’s performance profile demands a specific shape—a deque, a circular buffer, a persistent tree—you build it yourself. And if you want that structure to play nicely with Swift’s for loops, slicing, and standard algorithms, you make it a Collection.
Conforming to Collection isn’t just about looking good. It unlocks prefix(while:), split(separator:), joined(), and a pile of other methods. It lets your type participate in generic algorithms you’ve already written. And it signals to other developers that your type behaves predictably—no hidden side effects, no surprising complexity. The Swift Evolution proposal SE‑0065 formalized the index model, and it’s worth a read if you want the full rationale.

The Index: Heart of the Collection
Every custom collection starts with its Index type. This is where most people stumble. An index must be Comparable, and for a plain Collection you need to be able to advance it to the next position. For BidirectionalCollection, you also need to move it backward. For RandomAccessCollection, you need to measure the distance between indices and move an index by an arbitrary offset—both in O(1).
Let’s build something concrete: a RingBuffer that stores a fixed number of elements and overwrites the oldest when full. Handy for keeping a sliding window of sensor readings or recent log entries. We’ll make it a Collection so we can iterate over its contents in order from oldest to newest.
struct RingBuffer<Element> {
private var storage: [Element?]
private var writeIndex: Int = 0
private var count: Int = 0
let capacity: Int
init(capacity: Int) {
self.capacity = capacity
self.storage = Array(repeating: nil, count: capacity)
}
mutating func append(_ element: Element) {
storage[writeIndex] = element
writeIndex = (writeIndex + 1) % capacity
count = min(count + 1, capacity)
}
}
Straightforward ring buffer. writeIndex points to the next slot to fill, and count tracks how many elements are currently stored. But to make it a Collection, we need an index type that can navigate this circular storage.
Designing the Index
An index for a ring buffer needs to know its position relative to the start of the logical sequence. Since the buffer wraps, logical index 0 might correspond to a physical index that isn’t 0. We’ll store an integer offset from the start and compute the physical index on the fly.
extension RingBuffer: Collection {
struct Index: Comparable {
let offset: Int
let capacity: Int
static func < (lhs: Index, rhs: Index) -> Bool {
lhs.offset < rhs.offset
}
}
var startIndex: Index {
Index(offset: 0, capacity: capacity)
}
var endIndex: Index {
Index(offset: count, capacity: capacity)
}
func index(after i: Index) -> Index {
Index(offset: i.offset + 1, capacity: capacity)
}
subscript(position: Index) -> Element {
let physicalIndex = (writeIndex - count + position.offset) % capacity
if physicalIndex < 0 {
return storage[physicalIndex + capacity]!
}
return storage[physicalIndex]!
}
}
Notice the subscript uses writeIndex - count to find the physical start of the logical sequence. The modulo handles wrapping, and we adjust for negative values because Swift’s % operator preserves the sign of the dividend. This is one of those details that can crash in production if you’re not careful—I’ve debugged this exact issue at 2 a.m. after a release.
This index type is simple: just an offset. But it satisfies the requirements. startIndex is offset 0, endIndex is offset count, and index(after:) increments the offset. Because the offset is an Int, we can easily make this a BidirectionalCollection and RandomAccessCollection later if we need to.

Conforming to BidirectionalCollection and RandomAccessCollection
Once you have Collection working, adding BidirectionalCollection is often trivial. You just provide an index(before:) method. For our ring buffer, that means decrementing the offset.
extension RingBuffer: BidirectionalCollection {
func index(before i: Index) -> Index {
Index(offset: i.offset - 1, capacity: capacity)
}
}
RandomAccessCollection asks for a bit more: you need to measure the distance between two indices and advance an index by a given distance, both in O(1). Since our index is just an integer offset, this is straightforward.
extension RingBuffer: RandomAccessCollection {
func distance(from start: Index, to end: Index) -> Int {
end.offset - start.offset
}
func index(_ i: Index, offsetBy distance: Int) -> Index {
Index(offset: i.offset + distance, capacity: capacity)
}
}
Now our ring buffer supports all the standard collection operations, including slicing with ranges. But there’s a catch: the subscript we wrote uses ! to force‑unwrap the optional from storage. That’s safe as long as the index is valid, but if someone passes an out‑of‑bounds index, it’ll crash. The standard library collections trap on out‑of‑bounds access, so this is consistent behavior. Still, it’s worth documenting that your collection doesn’t do bounds checking beyond what the index model enforces.
MutableCollection and RangeReplaceableCollection
If you want your collection to support in‑place mutation through subscripts, add MutableCollection. This requires a setter on your subscript. For a ring buffer, setting an element at a valid index is straightforward, but you need to decide what happens if someone tries to set an element at endIndex. The standard answer: don’t allow it. endIndex is a “past the end” position, not a valid element index.
extension RingBuffer: MutableCollection {
subscript(position: Index) -> Element {
get {
let physicalIndex = (writeIndex - count + position.offset) % capacity
if physicalIndex < 0 {
return storage[physicalIndex + capacity]!
}
return storage[physicalIndex]!
}
set {
let physicalIndex = (writeIndex - count + position.offset) % capacity
if physicalIndex < 0 {
storage[physicalIndex + capacity] = newValue
} else {
storage[physicalIndex] = newValue
}
}
}
}
RangeReplaceableCollection is a different beast. It requires an initializer that takes an empty collection, plus methods to replace a subrange with new elements, reserve capacity, and append. For a fixed‑capacity ring buffer, this doesn’t make much sense—you can’t just “insert” elements without potentially overwriting others. In such cases, it’s better to skip RangeReplaceableCollection and instead provide custom mutating methods that match your data structure’s semantics, like append(_:) and removeFirst().
This is a key design principle: conform only to the protocols that make sense for your type. Don’t force a square peg into a round hole just to get free methods. Your future self (and your teammates) will thank you when the API behaves predictably.
Performance Considerations and Lazy Evaluation
Custom collections often expose performance characteristics that differ from Array. For example, our ring buffer has O(1) access by index, but iterating over it might be slightly slower due to the modulo arithmetic in the subscript. If you’re building a collection that wraps a complex data structure—say, a tree—you might want to provide a lazy view that defers traversal until it’s actually needed.
Swift’s LazySequence and LazyCollection protocols are powerful tools here. By conforming to LazyCollectionProtocol, you can make operations like map and filter lazy by default, avoiding intermediate allocations. But be cautious: lazy collections don’t cache their results, so repeated access can trigger repeated work. This is a classic tradeoff between memory and CPU, and the right choice depends on your use case.
For a deep dive into lazy evaluation, the Swift standard library source is surprisingly readable. You can browse it on GitHub to see how LazySequence and LazyCollection are implemented.

Practical Example: An Ordered Set
Let’s apply these concepts to a more complex type: an OrderedSet that maintains insertion order while ensuring uniqueness. This is a common need in iOS apps—think of a playlist that shouldn’t have duplicate songs, or a list of recently viewed items. Foundation’s NSOrderedSet exists, but it’s not generic over Swift types and requires bridging overhead. A native Swift implementation is cleaner and more performant.
struct OrderedSet<Element: Hashable> {
private var elements: [Element] = []
private var set: Set<Element> = []
mutating func append(_ element: Element) -> Bool {
if set.insert(element).inserted {
elements.append(element)
return true
}
return false
}
}
This uses an array for ordering and a set for O(1) membership checks. To make it a Collection, we can simply forward the index and subscript to the internal array, since the array already maintains the correct order.
extension OrderedSet: Collection {
typealias Index = Array<Element>.Index
var startIndex: Index { elements.startIndex }
var endIndex: Index { elements.endIndex }
subscript(position: Index) -> Element {
elements[position]
}
func index(after i: Index) -> Index {
elements.index(after: i)
}
}
Because we’re using Array’s index type, we get BidirectionalCollection and RandomAccessCollection for free—just add the conformance declarations. This is a pattern worth remembering: if your custom collection is backed by an array, you can often delegate the index work to it.
When to Use a Custom Collection vs. a Wrapper
Not every data structure needs to be a full Collection. If you’re building a cache that shouldn’t expose its internal order, or a priority queue where iteration order isn’t meaningful, consider providing a Sequence conformance instead. Sequence only requires a makeIterator() method, which is much simpler to implement. You can still use for loops and basic operations like map and filter, but you avoid the semantic contract of indexing.
Another option is to expose a collection property that returns a view of your data. For example, a graph might have a vertices collection and an edges collection, each conforming to Collection, while the graph itself doesn’t. This keeps the API surface small and focused.
Testing Your Custom Collection
Custom collections are notoriously easy to get wrong. Off‑by‑one errors in index math, incorrect endIndex behavior, and slicing bugs are common. I’ve found that property‑based testing with a framework like SwiftCheck is invaluable. You can generate random sequences of operations—appends, removals, iterations—and verify that your collection behaves identically to an array with the same contents.
At a minimum, write tests that cover:
- Empty collection:
startIndex == endIndex - Single element: iteration yields exactly one element
- Multiple elements: iteration order matches expected order
- Index traversal:
index(after:)andindex(before:)are inverses - Slicing: a slice has the same elements as the original in that range
- Edge cases: wrapping behavior in the ring buffer, duplicate handling in the ordered set
These tests will catch most bugs before they reach production. And if you’re shipping a library, include a test suite that runs on CI—your users will appreciate the reliability.
FAQ
What’s the difference between Sequence and Collection in Swift?
Sequence is a protocol that provides a single method, makeIterator(), which returns an iterator that can yield elements one at a time. It supports for loops and basic operations like map and filter, but it doesn’t guarantee that you can traverse it multiple times or access elements by index. Collection inherits from Sequence and adds the concept of indices, allowing you to access elements by position, traverse in both directions (if BidirectionalCollection), and slice the collection. In short, use Sequence when you just need to iterate; use Collection when you need random access or repeated traversal.
Why does my custom collection need an Index type? Can’t I just use Int?
You can use Int as your index type—many collections do, including Array. But the Collection protocol requires an associated Index type to support generic algorithms. Using Int is fine for simple cases, but a custom index type can encode additional information (like the capacity in our ring buffer) and prevent accidental misuse. For example, an index from one ring buffer shouldn’t be used with another, and a custom type can enforce that at compile time.
When should I conform to RandomAccessCollection vs. BidirectionalCollection?
Conform to RandomAccessCollection if you can compute the distance between any two indices and move an index by an arbitrary offset in O(1) time. This is typical for array‑backed collections. Conform to BidirectionalCollection if you can move forward and backward in O(1) but can’t measure distance efficiently—think of a linked list. If you can only move forward, stick with Collection. The more specific the conformance, the more algorithms your type unlocks, but don’t overpromise: if your distance calculation is O(n), don’t claim RandomAccessCollection, because generic code will assume O(1) and performance will suffer.
How do I handle mutating operations while iterating over a custom collection?
Swift’s standard collections trap if you mutate them during iteration, and your custom collection should do the same. The simplest approach is to use a “copy‑on‑write” pattern: make your collection a value type with internal reference semantics, and check isKnownUniquelyReferenced before mutating. If the reference isn’t unique, copy the storage. This prevents accidental mutation during iteration and keeps your collection’s semantics consistent with Array and Dictionary. For a detailed explanation, see the Swift Optimization Tips.
Building custom collections is a skill that pays off in cleaner APIs and better performance. Start with a clear understanding of your data structure’s semantics, implement the minimal protocol conformance, and test thoroughly. The next time you find yourself reaching for an array when a ring buffer or ordered set would be a better fit, you’ll know exactly what to do.