Swift Sequence and Collection Protocols Explained Clearly

Swift’s Sequence and Collection protocols are the foundation of how arrays, sets, dictionaries, strings, ranges, and many custom data types are iterated and accessed. If you understand these protocols, you will better understand why some types only support simple iteration, why others support indexing and repeated traversal, and how to design your own custom iterable types in Swift.

Quick answer: In Swift, a Sequence is any type you can iterate over, usually with a for-in loop. A Collection is a more capable kind of sequence that supports stable indexing, multiple passes over its elements, and efficient navigation from one index to another.

Difficulty: Intermediate

Helpful to know first: You'll understand this better if you know basic Swift syntax, how for-in loops work, and simple types such as Array, Set, and Dictionary.

1. What Is Sequence and Collection Protocols?

The Swift standard library uses protocols to describe shared behavior. Sequence describes types that can produce elements one at a time. Collection builds on that idea and adds stronger guarantees about indexing and traversal.

This distinction matters because many Swift methods are available at the protocol level. For example, both sequences and collections can use methods like map and filter, but only collections support index-based navigation requirements such as startIndex, endIndex, and subscripting by index.

2. Why Sequence and Collection Protocols Matters

These protocols matter because they let Swift write powerful generic APIs. A function can accept any Sequence if it only needs iteration, or any Collection if it needs indexing, counting behavior, or multi-pass access.

In real projects, this helps you:

Using the right protocol also communicates intent. If your code only needs to loop over elements once, accepting a Sequence is enough. If your code needs indexes or repeated passes, Collection is the safer requirement.

3. Basic Syntax or Core Idea

Defining a simple Sequence

To create a custom sequence, you must provide a makeIterator() method that returns an iterator. The iterator produces elements one by one.

struct Countdown: Sequence {
    let start: Int

    func makeIterator() -> AnyIterator<Int> {
        var current = start

        return AnyIterator {
            guard current >= 0 else {
                return nil
            }

            defer { current -= 1 }
            return current
        }
    }
}

This type can now be used in a for-in loop because it satisfies the Sequence protocol.

Defining a simple Collection

A collection needs more than iteration. It needs an index type, start and end indexes, a way to move to the next index, and a subscript.

struct NameCollection: Collection {
    private let items: [String]

    init(items: [String]) {
        self.items = items
    }

    var startIndex: Int { items.startIndex }
    var endIndex: Int { items.endIndex }

    func index(after i: Int) -> Int {
        items.index(after: i)
    }

    subscript(position: Int) -> String {
        items[position]
    }
}

This collection forwards its behavior to an underlying array. It can be iterated over like a sequence, but it also supports indexing and other collection operations.

Sequence vs Collection in one sentence

If you only need to produce values in order, use Sequence. If you also need stable index-based access and repeated traversal, use Collection.

4. Step-by-Step Examples

Example 1: Iterating over any Sequence

This function accepts any sequence of integers. It does not care whether the input is an array, a range, or a custom sequence.

func printSquares<S: Sequence>(from numbers: S) where S.Element == Int {
    for number in numbers {
        print(number * number)
    }
}

printSquares(from: [1, 2, 3])
printSquares(from: 4...6)

This works because both arrays and closed ranges conform to Sequence. The function only asks for iteration, so no collection-specific features are required.

Example 2: Using Collection indexes safely

Collections do not all use integer indexes in the same way. Swift provides collection APIs so your code works correctly across different collection types.

let names = ["Ava", "Ben", "Chris"]

let firstIndex = names.startIndex
print(names[firstIndex])

let secondIndex = names.index(after: firstIndex)
print(names[secondIndex])

This example uses the collection API instead of assuming how indexes work internally. That matters especially for strings, where direct integer indexing is not supported.

Example 3: Sequence methods such as map and filter

Many useful methods are defined on Sequence, so custom sequences automatically gain them once they conform correctly.

let evens = (1...10).filter { $0 % 2 == 0 }
let labels = evens.map { "Even: \($0)" }

print(labels)

Because ranges are sequences, they can use methods like filter and map. This is one reason Swift’s protocol-based design is so powerful.

Example 4: A custom countdown sequence

Here is the earlier custom sequence in use.

let countdown = Countdown(start: 3)

for value in countdown {
    print(value)
}

This prints values from 3 down to 0. The type is iterable, so it behaves like other sequence types in Swift.

Example 5: Why String is a Collection but not integer-indexed

String is a collection of characters, but its indexes are not plain integers. Swift does this to handle Unicode correctly.

let word = "Swift"
let firstCharacter = word[word.startIndex]
print(firstCharacter)

let nextIndex = word.index(after: word.startIndex)
print(word[nextIndex])

This shows why collection APIs are preferred over assumptions about index types. A collection guarantees navigation by valid indexes, not necessarily by integers.

5. Practical Use Cases

6. Common Mistakes

Mistake 1: Assuming every sequence can be indexed

Beginners often treat all iterable types like arrays. But Sequence only guarantees iteration, not indexing.

Problem: This function claims it only needs a sequence, but then tries to use collection-only features such as startIndex and subscripting.

func printFirst<S: Sequence>(values: S) {
    let first = values[values.startIndex]
    print(first)
}

Fix: If you only need the first element, use sequence APIs such as first. If you need indexing, require Collection instead.

func printFirst<S: Sequence>(values: S) {
    if let first = values.first {
        print(first)
    }
}

The corrected version works because first is available at the sequence level and does not require indexing.

Mistake 2: Using integer indexes with String

Many developers coming from other languages expect strings to support integer-based subscripting. Swift strings do not work that way.

Problem: This code tries to use an integer subscript on a string, which causes a compiler error because String uses String.Index.

let name = "Swift"
print(name[0])

Fix: Use the string’s collection indexes and move through them with collection methods.

let name = "Swift"
let firstIndex = name.startIndex
print(name[firstIndex])

The corrected version works because it uses the proper index type defined by the collection.

Mistake 3: Accessing endIndex as if it were a valid element position

endIndex marks the position just after the last valid element. It is not itself a valid subscript position.

Problem: This code attempts to read an element at endIndex, which causes a runtime trap for out-of-bounds access.

let numbers = [10, 20, 30]
print(numbers[numbers.endIndex])

Fix: Move back from endIndex when you want the last element, or use last.

let numbers = [10, 20, 30]
if let lastValue = numbers.last {
    print(lastValue)
}

The corrected version works because last safely returns the final element instead of using an invalid index.

Mistake 4: Requiring Collection when Sequence is enough

Sometimes developers over-constrain APIs. That makes functions less reusable than they need to be.

Problem: This function only sums values, but it unnecessarily requires a collection, excluding valid sequence-only types.

func sumAll<C: Collection>(values: C) -> Int where C.Element == Int {
    values.reduce(0, +)
}

Fix: Require only Sequence when all you need is iteration.

func sumAll<S: Sequence>(values: S) -> Int where S.Element == Int {
    values.reduce(0, +)
}

The corrected version works because it expresses the true minimum requirement and supports more input types.

7. Best Practices

Choose the least restrictive protocol

If your function only loops through values, prefer Sequence. If it needs indexing or multi-pass behavior, require Collection. This makes generic code more reusable.

func containsNegative<S: Sequence>(numbers: S) -> Bool where S.Element == Int {
    numbers.contains { $0 < 0 }
}

This is better than requiring a collection when you never use collection-specific features.

Use collection indexes instead of assumptions

Do not assume every collection uses integer indexes or that index movement is always simple arithmetic. Follow the protocol APIs.

let text = "Hello"
let secondIndex = text.index(text.startIndex, offsetBy: 1)
print(text[secondIndex])

This remains correct for strings because it respects Swift’s Unicode-aware indexing model.

Wrap storage when building custom collections

If you are learning custom collection conformance, a wrapper around an existing array is often the safest starting point. It lets you focus on protocol requirements without reinventing indexing.

struct Scores: Collection {
    private var storage: [Int]

    init(_ storage: [Int]) {
        self.storage = storage
    }

    var startIndex: Int { storage.startIndex }
    var endIndex: Int { storage.endIndex }
    func index(after i: Int) -> Int { storage.index(after: i) }
    subscript(position: Int) -> Int { storage[position] }
}

This approach lowers the risk of index logic bugs while still giving you a custom type.

8. Limitations and Edge Cases

A common “not working” situation is expecting an ArraySlice to restart at index 0. In Swift, slices usually preserve original indexes, so use startIndex rather than hard-coded integer assumptions.

9. Practical Mini Project

This small project creates a custom collection that wraps a list of tasks. It behaves like a normal Swift collection, so it supports iteration, subscripting, and standard collection methods.

struct TaskList: Collection {
    private let tasks: [String]

    init(_ tasks: [String]) {
        self.tasks = tasks
    }

    var startIndex: Int { tasks.startIndex }
    var endIndex: Int { tasks.endIndex }

    func index(after i: Int) -> Int {
        tasks.index(after: i)
    }

    subscript(position: Int) -> String {
        tasks[position]
    }
}

let todayTasks = TaskList([
    "Write report",
    "Review pull request",
    "Send update email"
])

for task in todayTasks {
    print(task)
}

print(todayTasks[todayTasks.startIndex])
print(todayTasks.map { $0.uppercased() })

This mini project shows the practical payoff of conforming to Collection. Once the protocol requirements are satisfied, your custom type automatically works with many standard library features.

10. Key Points

11. Practice Exercise

Try this exercise to check your understanding.

Expected output: The loop should print even numbers in order, and the mapped result should contain labels such as "Value: 0", "Value: 2", and so on.

Hint: Implement makeIterator() and return an iterator that increases by 2 each time until it passes the maximum.

struct EvenNumbers: Sequence {
    let maximum: Int

    func makeIterator() -> AnyIterator<Int> {
        var current = 0

        return AnyIterator {
            guard current <= maximum else {
                return nil
            }

            let value = current
            current += 2
            return value
        }
    }
}

let evens = EvenNumbers(maximum: 8)

for number in evens {
    print(number)
}

let labels = evens.map { "Value: \($0)" }
print(labels)

12. Final Summary

Sequence and Collection are central to Swift’s standard library design. A sequence gives you ordered iteration, while a collection gives you stronger guarantees such as stable indexes and multi-pass traversal. Understanding that difference helps you write more accurate generic code and avoid common mistakes, especially with strings, slices, and custom iterable types.

As you continue learning Swift collections, pay attention to what a protocol actually guarantees instead of assuming all iterable types behave like arrays. That habit will make your code safer, more reusable, and easier to reason about. A strong next step is to study BidirectionalCollection, RandomAccessCollection, and slicing behavior such as ArraySlice and Substring.