Building Custom Swift Collections: Protocols, Performance, and Practical Patterns
Sometimes an array is almost right—except you need every element to be unique. Or a dictionary would work, if only it remembered the order you added things. The Swift standard library doesn’t hand you a finished type for those moments. It gives you a set of protocols you can stitch together yourself. Custom collections let you model domain-specific data with native iteration, subscripting, and value semantics. This article walks through the protocol hierarchy—Sequence, Collection, BidirectionalCollection, and RandomAccessCollection—and shows how to implement each one with predictable performance. You’ll also see how to wrap existing storage, extend lazy sequences, and decide when a custom collection is worth the effort over a simple struct with an internal array.

Understanding the Swift Collection Protocol Hierarchy
Swift’s collection protocols stack in layers. At the top sits Sequence, which gives you the ability to iterate over elements one at a time. Conform to Sequence and you get for–in loops, map, filter, and dozens of other methods for free. One step down, Collection adds indexed access, a defined start and end, and the guarantee that multiple passes over the elements won’t destroy anything. Below Collection, BidirectionalCollection lets you move an index backward, and RandomAccessCollection promises that index advancement and distance measurement take O(1) time. Understanding this hierarchy is the first step toward choosing the right conformance for your type.
For most custom data structures, Collection is the sweet spot. It requires implementing startIndex, endIndex, index(after:), and a subscript that returns an element for a given index. If your data structure naturally supports efficient backward traversal—like a doubly linked list or a deque—add BidirectionalCollection. Reserve RandomAccessCollection for structures backed by contiguous memory or balanced trees where index arithmetic is cheap.
Sequence: The Minimum Viable Iteration
Conforming to Sequence requires only a makeIterator() method that returns a type conforming to IteratorProtocol. Often you can let the compiler synthesize the iterator by returning a type that already conforms to Sequence, such as an array. For example, a wrapper around a set can expose a sequence of sorted elements without implementing Collection:
struct SortedSet<Element: Comparable>: Sequence {
private var storage: Set<Element>
init(_ elements: Set<Element>) {
self.storage = elements
}
func makeIterator() -> Array<Element>.Iterator {
return storage.sorted().makeIterator()
}
}
This approach is quick and gives you functional transformations, but you lose subscript access and the ability to measure the count in O(1) time. Use Sequence-only conformance when your data does not have a natural notion of position, such as a stream of sensor readings or a lazily generated infinite series.
Implementing a Full Collection: The OrderedSet Example
An ordered set maintains insertion order while guaranteeing element uniqueness. The standard library lacks this type, but Foundation’s NSOrderedSet bridges awkwardly to Swift. A native implementation that conforms to Collection gives you value semantics, copy-on-write behavior, and smooth use with SwiftUI lists and Combine pipelines.
Start by defining the storage. A private array holds the ordered elements, and a set provides O(1) membership checks. The index type can be a simple wrapper around Int:
public struct OrderedSet<Element: Hashable> {
fileprivate var elements: [Element] = []
fileprivate var set: Set<Element> = []
public init() {}
}
To conform to Collection, you need to define an Index type and the required methods. Using Int as the index is straightforward and automatically satisfies Comparable:
extension OrderedSet: Collection {
public typealias Index = Int
public var startIndex: Int { elements.startIndex }
public var endIndex: Int { elements.endIndex }
public func index(after i: Int) -> Int {
elements.index(after: i)
}
public subscript(position: Int) -> Element {
elements[position]
}
}
Because Int already conforms to Comparable and Strideable, you can also adopt BidirectionalCollection and RandomAccessCollection with no extra work—just declare conformance. This is a common pattern when your underlying storage is an array.

Adding Mutation and Value Semantics
To make the ordered set useful, add mutation methods that maintain the internal consistency between the array and the set. Each insertion must check the set before appending to the array. Each removal must update both structures. To preserve value semantics—so that assigning one ordered set to another creates an independent copy—wrap the storage in a copy-on-write container or simply use two stored properties and rely on the compiler’s synthesized copy behavior, which works because Array and Set are themselves value types with copy-on-write.
extension OrderedSet {
@discardableResult
public mutating func insert(_ newElement: Element) -> Bool {
if set.contains(newElement) {
return false
}
elements.append(newElement)
set.insert(newElement)
return true
}
public mutating func remove(_ element: Element) -> Element? {
guard let index = elements.firstIndex(of: element) else { return nil }
set.remove(element)
return elements.remove(at: index)
}
}
This design gives you O(1) membership tests and O(n) removal, which is acceptable for many use cases. If you need faster removal, consider a different backing store, such as a doubly linked list with a dictionary mapping elements to nodes. That approach adds complexity but keeps removals O(1).
Conforming to ExpressibleByArrayLiteral and Equatable
For ergonomics, adopt ExpressibleByArrayLiteral so users can initialize an ordered set with an array literal. This requires implementing init(arrayLiteral:) and filtering out duplicates while preserving order:
extension OrderedSet: ExpressibleByArrayLiteral {
public init(arrayLiteral elements: Element...) {
for element in elements {
insert(element)
}
}
}
Equatable conformance is also straightforward. Two ordered sets are equal if they contain the same elements in the same order. Because the internal array already preserves order, you can delegate to it:
extension OrderedSet: Equatable {
public static func == (lhs: OrderedSet, rhs: OrderedSet) -> Bool {
lhs.elements == rhs.elements
}
}
With these additions, the ordered set behaves like a native Swift collection. You can iterate over it, access elements by index, compare instances, and initialize with an array literal.
Using Lazy Sequences and Wrappers
Not every custom collection needs to store data. Sometimes you want to present a view over existing data that transforms or filters it on the fly. Swift’s LazySequence and LazyCollection protocols are designed for this, but you can also build your own wrapper types that conform to Collection. For example, a Chunked collection can split a base collection into fixed-size slices without allocating new storage:
struct Chunked<Base: Collection>: Collection {
let base: Base
let size: Int
struct Index: Comparable {
var offset: Base.Index
static func < (lhs: Index, rhs: Index) -> Bool {
lhs.offset < rhs.offset
}
}
var startIndex: Index { Index(offset: base.startIndex) }
var endIndex: Index { Index(offset: base.endIndex) }
func index(after i: Index) -> Index {
let next = base.index(i.offset, offsetBy: size, limitedBy: base.endIndex) ?? base.endIndex
return Index(offset: next)
}
subscript(position: Index) -> Base.SubSequence {
let end = base.index(position.offset, offsetBy: size, limitedBy: base.endIndex) ?? base.endIndex
return base[position.offset..<end]
}
}
This pattern is powerful for building lazy pipelines without intermediate allocations. The Chunked collection stores only a reference to the base collection and the chunk size, so its memory footprint is tiny. Each subscript call slices the base collection on demand.
Performance Considerations and Tradeoffs
When you implement a custom collection, the protocol requirements dictate the performance guarantees you must provide. The Swift standard library documents expected complexity for each operation. For example, Collection requires that index(after:) be O(1) and that startIndex and endIndex be O(1). If your data structure cannot meet these guarantees, you should not conform to Collection; instead, conform only to Sequence.
Copy-on-write (CoW) is another critical consideration. If your custom collection wraps a class instance or uses reference types internally, you must implement CoW manually to preserve value semantics. The standard pattern is to use a private class for storage and check isKnownUniquelyReferenced before mutating. Failing to do this can lead to unexpected shared state, where modifying one variable affects another. For many custom collections, however, you can avoid this complexity by composing value types like arrays and dictionaries, which already provide CoW.
When to Build a Custom Collection vs. a Simple Wrapper
Before writing a full custom collection, ask whether a struct with a single stored property and a few convenience methods would suffice. If you only need to add, remove, and iterate over elements, a struct with an internal array and a forEach method is often enough. Conforming to Collection becomes valuable when you want your type to work with Swift’s generic algorithms—prefix, suffix, split, reduce, and so on—or when you need to pass it to functions that take a Collection parameter. The protocol conformance also signals to other developers that your type behaves like a standard collection, reducing the learning curve.

Integrating with SwiftUI and Combine
Custom collections that conform to RandomAccessCollection work smoothly with SwiftUI’s List and ForEach views. Because ForEach relies on RandomAccessCollection for stable, identified elements, your ordered set can directly drive a list of unique, ordered items. Similarly, Combine’s Publisher extensions that accept collections, such as publisher on a sequence, become available automatically. This interoperability is a strong reason to invest in full protocol conformance rather than exposing a custom API.
For example, an ordered set of Identifiable items can be used directly in a SwiftUI List without any adapter code:
struct ContentView: View {
@State private var items: OrderedSet<Item> = []
var body: some View {
List(items) { item in
Text(item.name)
}
}
}
This integration is possible because OrderedSet conforms to RandomAccessCollection, and Item conforms to Identifiable. The compiler synthesizes the necessary id key path from the Identifiable conformance.
Testing Custom Collections with XCTest
Thorough testing is essential for custom collections. You should verify that all protocol requirements are met and that the collection behaves correctly under edge cases. Test empty collections, single-element collections, and collections with many elements. Check that indices remain valid after mutations and that iteration produces the expected order. For ordered sets, test that duplicate insertions are rejected and that removals update both the array and the set.
Use Swift’s XCTAssertEqual to compare collections directly, which relies on Equatable conformance. Also test that your collection works with standard library algorithms. For example, mapping over an ordered set should return an array of the same length in the same order:
func testMapPreservesOrder() {
var set: OrderedSet<Int> = [3, 1, 2]
let mapped = set.map { $0 * 2 }
XCTAssertEqual(mapped, [6, 2, 4])
}
Performance tests are equally important. Use XCTest’s measure block to ensure that your index operations and mutations meet the expected complexity. If you claim O(1) subscript access, a performance test with a large collection should show constant-time behavior.
FAQ
What is the difference between Sequence and Collection in Swift?
Sequence provides a single-pass iteration over elements. It does not guarantee that you can traverse the elements multiple times, and it has no concept of indices. Collection extends Sequence and adds indexed access, a defined start and end, and the guarantee that multiple passes are nondestructive. Conform to Collection when you need subscript access or want to use algorithms that require bidirectional or random-access traversal.
When should I implement a custom collection instead of using an array or dictionary?
Implement a custom collection when the standard library types do not match your domain constraints. Examples include an ordered set (unique elements in insertion order), a ring buffer (fixed-size circular queue), or a lazy view that transforms elements without allocating new storage. If your needs are met by an array with a few helper methods, a custom collection may be over-engineering. The key signal is whether you need your type to work with generic algorithms that expect Collection conformance.
How do I ensure my custom collection has value semantics?
If your collection stores only value types (structs, enums, arrays, dictionaries), Swift automatically provides value semantics through copy-on-write. If you use a reference type (a class) for internal storage, you must implement copy-on-write manually. The standard pattern is to wrap the class in a struct, check isKnownUniquelyReferenced before mutating, and create a copy of the storage if it is shared. This preserves the behavior that assigning the collection to a new variable creates an independent copy.
Can I make my custom collection work with SwiftUI’s ForEach?
Yes. ForEach requires a RandomAccessCollection of elements that are Identifiable or that you provide a key path to a stable identifier. If your custom collection conforms to RandomAccessCollection and its elements are Identifiable, you can use it directly in a List or ForEach without any adapter. This is one of the practical benefits of full protocol conformance.
Next Steps for Your Swift Codebase
Custom collections are a powerful tool when the standard library falls short of your domain model. Start by identifying a concrete need—an ordered set, a chunked view, a ring buffer—and implement the minimal protocol conformance that satisfies it. Test thoroughly, document the performance characteristics, and consider contributing the type to a shared package if it proves generally useful. As your codebase grows, these small, focused abstractions will reduce duplication and make your intent clearer to other developers.
For further reading, the Swift Sequence documentation and the Swift Collections package from Apple provide additional examples and production-ready implementations of common data structures.