Generics in Swift — Constraints, Metadata, and the Generic Standard Library
Generics let one piece of code work over many types with full type safety. And unlike Java, Swift keeps the type information alive at runtime rather than erasing it.
The duplication problem — one algorithm, many types
Let's start with a tiny task: swapping two values. In Swift, before generics enter the picture, we might reach for something like this.
func swapInts(_ a: inout Int, _ b: inout Int) {
let temp = a
a = b
b = temp
}
func swapStrings(_ a: inout String, _ b: inout String) {
let temp = a
a = b
b = temp
}
func swapDoubles(_ a: inout Double, _ b: inout Double) {
let temp = a
a = b
b = temp
}
Look at those three functions. The bodies are identical. The only thing that changes is the type name in the signature. Every new type we want to swap means another copy-paste, another place to fix a bug, another function to keep in sync. That's the duplication problem in miniature.
So what's the algorithm here, really? "Take two storage locations of the same type, exchange their contents." Nothing about that depends on whether the values are integers or strings. The shape of the code is the same for every type. We're just lacking a way to say "any type" without giving up the compiler's checks.
Generics are that way. We write the body once, with a placeholder standing in for the concrete type, and the caller supplies the real type at each call site.
func swap<T>(_ a: inout T, _ b: inout T) {
let temp = a
a = b
b = temp
}
var x = 1, y = 2
swap(&x, &y) // T is Int
var s = "a", t = "b"
swap(&s, &t) // T is String
A nice piece of trivia: this exact function ships in the Swift standard library as
swap(_:_:), and the generic <T> swap was one of the canonical examples in Apple's very first The Swift Programming Language book back in 2014. It is the "hello, world" of generics for a reason — it shows, in five lines, why writing the same algorithm by hand for each type is a waste.
Generic functions and type parameters
That <T> after the function name is a type parameter. It's a placeholder, a stand-in for a real type that the caller will decide. The letter T is just a convention (it stands for "Type"); we could call it anything, and for clarity we often do — Element, Key, Value.
Here's a slightly meatier example: finding the index of the first value in an array that matches a target.
func firstIndex<T: Equatable>(of value: T, in array: [T]) -> Int? {
for (index, element) in array.enumerated() {
if element == value {
return index
}
}
return nil
}
let names = ["Anna", "Bo", "Cleo"]
firstIndex(of: "Bo", in: names) // Optional(1), T is String
firstIndex(of: 7, in: [3, 7, 9]) // Optional(1), T is Int
Notice we never wrote firstIndex<String>(...) at the call site. We just passed a String and an array of strings, and Swift worked out that T must be String. This is type inference filling in the type parameter for us. The compiler looks at the arguments, sees what types they are, and binds T accordingly — once, consistently, for that whole call.
That consistency matters. Because both parameters use the same
T, the compiler insists they agree. We cannot pass a String as the value and an array of Int as the haystack. Reusing the same type parameter is how we say "these must be the same type", and the compiler holds us to it.
And here's the question that leads us straight into the next idea. How did
element == value even compile? Comparing two arbitrary values of an unknown type T with == shouldn't be allowed — not every type supports equality. The answer is that little : Equatable hanging off the type parameter. Without it, the function wouldn't build. Let's look at what that is.
Constraints — telling the compiler what T can do
Here's the catch with an unconstrained type parameter. If all the compiler knows is "some type T", then it cannot let us do much with a value of that type. We can move it around, store it, return it. But we cannot add it, compare it, or call methods on it, because T might be a type that doesn't support any of that.
func largest<T>(_ values: [T]) -> T? {
var result = values.first
for value in values {
if value > result! { // error: T might not support >
result = value
}
}
return result
}
The compiler is right to complain. What if T is some type with no notion of order? A constraint is how we promise the compiler that T supports a particular capability — and, in exchange, the compiler lets us use that capability inside the function.
func largest<T: Comparable>(_ values: [T]) -> T? {
var result = values.first
for value in values {
if value > result! { // fine: Comparable guarantees >
result = value
}
}
return result
}
largest([3, 1, 4, 1, 5]) // Optional(5)
largest(["pear", "fig", "kiwi"]) // Optional("pear")
Think of a constraint as a gate. Only types that satisfy it are allowed through to fill in T, and once inside, the constraint unlocks the operations we promised.
We can require more than one capability at once by combining constraints with
&.
func describe<T: Comparable & CustomStringConvertible>(_ a: T, _ b: T) {
let bigger = a > b ? a : b
print("The larger is \(bigger.description)")
}
And for richer requirements — especially ones that talk about the associated types we'll meet shortly — we reach for a where clause. A where clause lets us add conditions that a bare : SomeProtocol can't express.
func sum<S: Sequence>(_ values: S) -> Int
where S.Element: Numeric, S.Element == Int {
values.reduce(0, +)
}
sum([1, 2, 3]) // 6
sum(1...10) // 55, works on a Range too
The shorthand <T: Comparable> and the longer where T: Comparable mean the same thing; the where form simply has room for conditions about associated types and equality relationships. The rule of thumb: simple "this type conforms to that protocol" goes inline, anything about Element or type equality goes in a where clause.
Generic types — building Stack, and a generic standard library
Functions aren't the only things that can be generic. Types can be too. Let's build the classic example, a stack — a last-in, first-out collection — that holds elements of any single type.
struct Stack<Element> {
private var items: [Element] = []
mutating func push(_ item: Element) {
items.append(item)
}
mutating func pop() -> Element? {
items.popLast()
}
var top: Element? { items.last }
var count: Int { items.count }
}
var numbers = Stack<Int>()
numbers.push(1)
numbers.push(2)
numbers.pop() // Optional(2)
var words = Stack<String>()
words.push("hi")
The type parameter Element sits right after the type name, and from then on it's a real type we can use for stored properties, parameters, and return types. A Stack<Int> and a Stack<String> are genuinely different types — you cannot accidentally push a string onto an integer stack, and the compiler proves it.
Here's the part that surprises people coming from older languages. Once you notice generic types, you start seeing them everywhere in Swift — because the standard library is generic right down to its foundations. The everyday types you use without thinking are all generic.
// Every one of these is a generic type:
Array<Element> // [Int] is sugar for Array<Int>
Dictionary<Key, Value> // [K: V] is sugar for Dictionary<K, V>
Set<Element>
Optional<Wrapped> // Int? is sugar for Optional<Int>
Result<Success, Failure>
That little ? for optionals? It's syntactic sugar for Optional<Wrapped>, an ordinary generic enum. The square brackets for arrays and dictionaries? Sugar for Array<Element> and Dictionary<Key, Value>. There is no special magic compiler type for "list" the way some languages have. Swift's collections are written in Swift, as generic types, using the very same tools available to us. When we write our own Stack<Element>, we are building exactly the kind of thing the standard library is built from.
Associated types — the input on the other side
Generic types put their type parameter in angle brackets up front: Stack<Element>. Protocols play the same game, but from the other direction. A protocol can't take a type parameter in angle brackets. Instead it declares an associated type — a placeholder that each conforming type must fill in.
The canonical example is Sequence, the protocol behind every for-in loop in Swift.
protocol Sequence {
associatedtype Element
func makeIterator() -> ...
}
extension Array: Sequence {
// Element is inferred from how Array uses it
}
The difference is one of direction. With a generic struct, the caller chooses the type — we write Stack<Int> and pick Int ourselves. With an associated type, the conforming type chooses. When Array<Int> conforms to Sequence, it fixes Element to Int; when String conforms, its Element is Character. The protocol just says "there will be an Element"; each adopter decides what it is.
That's why you write
some Sequence rather than Sequence<Int> in older Swift — the protocol had no slot to put the Int in. You constrained the associated type indirectly through a where clause.
func total<S>(_ seq: S) -> Int where S: Sequence, S.Element == Int {
seq.reduce(0, +)
}
That's verbose. So Swift 5.7 introduced primary associated types (SE-0346), which let a protocol promote one or more associated types into angle brackets, just like a generic type's parameter.
// The declaration now reads:
protocol Sequence<Element> { associatedtype Element; ... }
// Which gives us this lovely shorthand:
func total(_ seq: some Sequence<Int>) -> Int {
seq.reduce(0, +)
}
let xs: some Collection<Int> = [1, 2, 3]
So Collection<Int> finally reads the way you'd hope — "a collection of Int" — even though under the hood it's still an associated type, not a type parameter. It's a piece of syntax that closed a long-standing readability gap between protocols and generic types.
The runtime model — metadata, witness tables, and specialisation
Now for the part that makes Swift's generics genuinely distinctive. The question every generics system has to answer is: when the program actually runs, what happens to the type? There are roughly three schools of thought, and Swift picks a fourth path between them.
Java erases. A List<String> and a List<Integer> compile down to the same raw List; the type argument is thrown away after the compiler has finished checking. At runtime there is no String in there at all, which is why you cannot ask list instanceof List<String> in Java.
C++ stamps out copies. A template is a recipe, and the compiler bakes a separate, fully concrete function or class for every type you ever use it with. std::vector<int> and std::vector<double> become two distinct compiled bodies. Fast, but it can lead to serious code bloat when many types are involved.
Swift keeps the type, and shares the code. By default the compiler emits one shared implementation of a generic function. It works for any type because the type's information — its size, how to copy it, how it conforms to a protocol — is passed in at runtime as type metadata and protocol witness tables. The witness table is essentially a lookup of "here is how this concrete type satisfies that protocol's requirements". So there's no erasure: the type really is present at runtime.
But passing metadata around has a cost. So Swift adds an escape hatch: specialisation. When the optimiser can see exactly which type a generic is being used with — and it often can, especially with @inlinable code — it stamps out a concrete, C++-style copy with the type baked in. A hot max<T> call on Int can become a tight, metadata-free max-for-Int. So you get the flexibility and small binary of a shared body by default, with the speed of a concrete copy where it actually matters.
So Swift sits in the middle. It doesn't erase like Java, so the type survives at runtime and you keep full type information. It doesn't blindly copy like C++, so a generic used with a hundred types doesn't necessarily blow up your binary a hundredfold. And when performance demands it, the optimiser quietly produces the concrete copy anyway.
Power features — conditional conformance and variadic generics
With the core in place, let's look at two of the more advanced corners, both of which you rely on constantly even if you never write them by hand.
Conditional conformance (SE-0143, Swift 4.1) lets a generic type conform to a protocol only when its type parameter does. The textbook example: an array is itself Equatable exactly when its elements are.
extension Array: Equatable where Element: Equatable {
// == compares element by element
}
[1, 2, 3] == [1, 2, 3] // true — Int is Equatable
// [SomeNonEquatable] == [...] // won't compile — and rightly so
This is why [Int] can be compared with == but an array of a type that isn't itself comparable cannot. The conformance is conditional on the element. The same trick gives you [T] being Hashable when T is, Optional<T> being Equatable when T is, and so on, all the way through the standard library.
Variadic generics (SE-0393, Swift 5.9) tackle a problem that plagued Swift for years: how do you write a function generic over an arbitrary number of type parameters? Before 5.9, the standard library was littered with hand-written overloads — a
zip for two sequences, the tuple == defined separately for arity 2, 3, 4, 5, 6 — because there was no way to say "a tuple of any number of types".
// A parameter pack: zero or more type parameters at once
func tie<each T>(_ values: repeat each T) -> (repeat each T) {
(repeat each values)
}
tie(1, "two", 3.0) // (Int, String, Double)
tie(true) // (Bool)
The each T declares a parameter pack — a placeholder for a whole sequence of type parameters — and repeat expands over it. One function now covers every arity, replacing what used to be a stack of copy-pasted overloads.
Step back and it all connects. SwiftUI's
View hierarchy is generic from top to bottom — a VStack is generic over its Content, and the result builder that assembles your view tree leans on these exact features. The standard library you reach for every day — Array, Dictionary, Optional, Result — is generic to its core. Generics aren't a niche feature you opt into. In Swift, they are the material the language itself is made from.
Test your understanding
7 questions
Seven questions covering generic functions, constraints, generic types, associated types, the runtime model, and Swift's power features.