Building a Custom Collection Type in Swift: Conforming to Collection

When Array Isn’t Enough

Most production Swift code leans heavily on Array, Set, and Dictionary. They’re fast, battle-tested, and cover the vast majority of use cases. But every so often you run into a data structure that doesn’t map cleanly onto these defaults. Maybe you need a ring buffer for a real-time audio pipeline, a sorted bag that maintains insertion order, or a tree that exposes a flattened traversal. In those moments, reaching for a custom collection isn’t over-engineering—it’s the clearest way to express your intent while preserving the ergonomics of Swift’s standard library.

The Swift team designed the Collection protocol to be adopted by your own types. Doing so gives your users access to forin loops, subscripting, map, filter, reduce, and dozens of other methods for free. The protocol hierarchy looks intimidating at first glance, but the practical path is narrower than the documentation suggests. This article walks through a concrete implementation—a fixed-capacity ring buffer—and explains the decisions you’ll face along the way.

Close-up of a polished metal ring on a dark surface, evoking the circular structure of a ring buffer

Why Build a Custom Collection?

Before writing any code, it’s worth asking whether a custom type is truly necessary. A thin wrapper around Array with a few convenience methods often suffices. The tipping point arrives when the default semantics of Array—contiguous storage, copy-on-write, and O(n) insertion at the front—conflict with your performance requirements. A ring buffer, for instance, provides O(1) amortized prepend and append operations by maintaining a fixed block of memory and two indices. Wrapping that logic in a type that looks like a collection to the rest of your codebase keeps the performance characteristics explicit and prevents accidental misuse.

Conforming to Collection also signals to other developers that your type is a sequence of elements with a defined start and end. It’s a contract. When you fulfill it correctly, standard algorithms compose naturally. When you cut corners, you get subtle bugs—index invalidation, infinite loops, or crashes from out-of-bounds access that slip past code review because everyone assumes Collection conformance implies safety.

The Ring Buffer: A Concrete Example

A ring buffer (also called a circular buffer) stores elements in a fixed-size array. Two pointers—head and tail—track where the logical sequence begins and ends. When the buffer is full, appending a new element overwrites the oldest one. This behavior is ideal for sliding-window algorithms, audio sample buffers, and logging systems where you only care about the most recent N entries.

We’ll build a RingBuffer that stores elements of a generic type Element. The storage will be a contiguously allocated array of optional Element values, initialized to nil. The optional wrapper is necessary because the buffer’s logical count can be smaller than its capacity, leaving uninitialized slots between tail and head in the circular layout.

Defining the Index

Every collection needs an Index type that is Comparable. For a ring buffer, the natural index is an integer offset from the logical start. But a raw Int is ambiguous: does it represent a position in the underlying storage or a logical position in the sequence? We’ll wrap it in a dedicated struct to make the distinction clear and to prevent accidental indexing into the backing array.

public struct RingBufferIndex: Comparable {
    fileprivate let offset: Int

    public static func < (lhs: RingBufferIndex, rhs: RingBufferIndex) -> Bool {
        lhs.offset < rhs.offset
    }
}

The fileprivate access level on the initializer is deliberate. Only the RingBuffer itself should create these indices, because it knows how to map a logical offset to a physical storage position. Exposing a public initializer would let anyone construct an index that might be invalid for a given buffer instance.

Conforming to Collection: The Minimum Requirements

The Collection protocol requires three things: a startIndex, an endIndex, and a subscript that takes an index and returns an element. It also requires index(after:), inherited from Sequence. Here’s the skeleton:

public struct RingBuffer {
    private var storage: [Element?]
    private var head: Int = 0
    private var tail: Int = 0
    private var count: Int = 0
    public let capacity: Int

    public init(capacity: Int) {
        precondition(capacity > 0, "Capacity must be greater than zero.")
        self.capacity = capacity
        self.storage = Array(repeating: nil, count: capacity)
    }
}

extension RingBuffer: Collection {
    public typealias Index = RingBufferIndex

    public var startIndex: Index {
        Index(offset: 0)
    }

    public var endIndex: Index {
        Index(offset: count)
    }

    public func index(after i: Index) -> Index {
        Index(offset: i.offset + 1)
    }

    public subscript(position: Index) -> Element {
        let physicalIndex = (head + position.offset) % capacity
        guard let element = storage[physicalIndex] else {
            preconditionFailure("Index out of bounds or accessing uninitialized storage.")
        }
        return element
    }
}

The subscript implementation reveals the core mapping: a logical offset is added to head and wrapped around the capacity. The force-unwrap is safe only if the collection’s invariants hold—namely, that every logical position between startIndex and endIndex corresponds to a non-nil slot. We’ll enforce that invariant in the mutation methods.

Abstract circular metal rings overlapping, representing the logical wrapping of indices in a ring buffer

Adding Mutation Methods

A read-only ring buffer has limited utility. We need append(_:) to add elements and, optionally, a way to remove them. The append operation is where the ring buffer’s performance advantage materializes: instead of shifting elements, we simply advance the tail pointer and, if the buffer is full, advance head as well.

extension RingBuffer {
    public mutating func append(_ element: Element) {
        if count == capacity {
            // Overwrite the oldest element.
            storage[head] = nil
            head = (head + 1) % capacity
        } else {
            count += 1
        }
        storage[tail] = element
        tail = (tail + 1) % capacity
    }
}

Notice that we nil out the overwritten slot. This prevents stale references from keeping heap-allocated objects alive longer than expected—a subtle memory management issue that can cause leaks in reference-type elements.

Making It a BidirectionalCollection

If your collection supports efficient reverse traversal, conforming to BidirectionalCollection unlocks reversed(), last, and other useful properties. The only additional requirement is index(before:).

extension RingBuffer: BidirectionalCollection {
    public func index(before i: Index) -> Index {
        Index(offset: i.offset - 1)
    }
}

Because our index is a simple integer offset, moving backward is trivial. For more complex index types—like those in a tree traversal—this method can become the most involved part of the conformance.

Conforming to RandomAccessCollection

The real prize is RandomAccessCollection. This protocol tells the standard library that you can compute the distance between indices and advance an index by an arbitrary amount in O(1) time. In return, algorithms like sort and binary search become available, and generic code can optimize for your type.

extension RingBuffer: RandomAccessCollection {
    public func distance(from start: Index, to end: Index) -> Int {
        end.offset - start.offset
    }

    public func index(_ i: Index, offsetBy distance: Int) -> Index {
        Index(offset: i.offset + distance)
    }
}

The distance and index(_:offsetBy:) methods are straightforward for our integer-based index. The standard library uses these to implement index(_:offsetBy:limitedBy:) automatically, which is another requirement of RandomAccessCollection.

Handling Edge Cases and Invariants

A collection that conforms to RandomAccessCollection but violates its semantic contract is worse than one that only conforms to Collection. Generic algorithms rely on the O(1) guarantee; if your index(_:offsetBy:) secretly does O(n) work, you’ll get mysterious performance regressions. Be honest about your type’s capabilities. If you can’t guarantee O(1) index advancement, stop at BidirectionalCollection.

Another common pitfall is index invalidation. Swift’s Array invalidates indices after a mutation that changes capacity. Our RingBuffer has a fixed capacity, so indices remain valid across appends—but only if the buffer isn’t full. If an append overwrites the element at the current head, any index pointing to that element now points to a different logical position. Document this behavior clearly, or consider making the buffer a value type with copy-on-write semantics so that mutations create a new logical snapshot.

When to Use a Custom Collection vs. a Wrapper

The decision tree is simpler than most developers think. If your type’s primary purpose is to hold and iterate over elements, conform to Collection. If you’re building a parser, a cache, or a state machine that happens to produce a sequence of values, conform to Sequence instead. The distinction matters because Collection implies multi-pass iteration and a stable, addressable set of elements. A Sequence can be single-pass and destructive.

For the ring buffer, Collection is the right choice. Users expect to read elements at arbitrary positions without consuming them. They expect count to reflect the current number of stored elements. And they expect for element in buffer to iterate in logical order, from oldest to newest. All of these are natural fits for the protocol.

A developer's hands typing on a MacBook keyboard, representing the implementation phase of a custom Swift collection

Testing Your Custom Collection

The standard library’s test suite for Collection conformance is extensive, but you can catch most bugs with a focused set of unit tests. Verify that startIndex and endIndex behave correctly for empty and non-empty states. Test index(after:) and index(before:) at the boundaries. For RandomAccessCollection, confirm that index(_:offsetBy:) and distance(from:to:) are consistent. Finally, exercise the collection with standard library algorithms like map, filter, and prefix to ensure they don’t trigger a runtime error.

One subtle bug to watch for: the interaction between count and endIndex. The endIndex should be exactly count steps away from startIndex. If your internal count property gets out of sync with the distance between head and tail, iteration will either terminate early or run past valid elements. A good invariant check is to assert that distance(from: startIndex, to: endIndex) == count in your test teardown.

Performance Considerations

The ring buffer’s append operation is O(1) amortized, but the subscript requires a modulo operation. On modern Apple Silicon, integer division is fast, but in a tight loop, the modulo can become a bottleneck. If profiling reveals an issue, you can replace the modulo with a bitwise AND when the capacity is a power of two. This is a micro-optimization that’s rarely necessary, but it’s worth knowing about.

Another performance factor is the optional wrapping of Element. If Element is a class type, the optional adds a reference-counting overhead. For value types, the compiler can often optimize away the optional when it can prove the slot is always occupied. Measure before you optimize, but keep the optional if it simplifies the implementation—clarity usually beats a few nanoseconds.

FAQ

Why not just use Array and call removeFirst()?

Array.removeFirst() is an O(n) operation because it shifts all remaining elements to fill the gap. For a buffer that holds thousands of elements and frequently removes from the front, this cost adds up quickly. A ring buffer avoids the shift entirely by moving pointers, making both prepend and append O(1).

Does conforming to Collection automatically give me Equatable and Hashable conformance?

No. Equatable and Hashable are separate protocols. However, if your Element type is Equatable, you can add Equatable conformance to your custom collection with a single line: extension RingBuffer: Equatable where Element: Equatable {}. The compiler will synthesize the implementation using the Collection conformance. The same applies to Hashable when Element is Hashable.

What’s the difference between Sequence and Collection?

Sequence represents a series of values that can be iterated once or multiple times, depending on the implementation. It makes no guarantees about element addressability or iteration cost. Collection inherits from Sequence and adds the ability to access elements by index, traverse bidirectionally (if BidirectionalCollection), and measure distances in constant time (if RandomAccessCollection). If your type can answer “what is the third element?” without iterating from the start, it should be a Collection.

Can I make my custom collection support for-in without conforming to Sequence?

No. The for-in loop in Swift requires Sequence conformance. However, Collection inherits from Sequence, so any Collection automatically supports iteration. If you only need iteration and not indexing, conform to Sequence directly—it’s a lighter contract.

Next Steps for Your Codebase

Once you’ve built a custom collection, the natural next step is to make it generic enough to reuse across projects. Consider extracting it into a Swift package with a focused API surface. Add ExpressibleByArrayLiteral conformance for ergonomic initialization. If the collection is a value type, implement copy-on-write to avoid unexpected sharing. And always document the performance characteristics and index invalidation rules—your future self (and your teammates) will thank you.

The ring buffer is just one example. The same pattern applies to skip lists, B-trees, gap buffers, and any other data structure that benefits from a collection interface. The Swift standard library’s protocol-oriented design rewards the effort with a type that feels native and composes smoothly with the rest of the ecosystem.