Generics in Swift
What generics are, how to write them, when to constrain types with Comparable or Equatable, and why it's normal to arrive at them after writing repetitive code first.
In the property wrappers post we used Clamped<Value: Comparable>, and in the feature flags post UserDefault<Value>. Both worked, both made sense in context — but neither was explained in detail. We put them there, they did their job, and we moved on as if nothing happened.
It’s an easy pattern to fall into: when something works, the explanation can wait. The problem is it waits too long, and you end up using a tool without really understanding what you have in your hands.
Just like conditional compilation needed its own post to complement the feature flags one, generics deserve the same to complete the property wrappers post. They’re one of those topics that show up everywhere in Swift — in the stdlib, in third-party code, in your own — without anyone stopping to explain why. Here’s that explanation.
Why they exist
Without generics, if you want a stack that works with integers, you write it for Int. If you want one for strings, you write it again for String. The logic is identical — only the type changes.
struct IntStack {
private var items: [Int] = []
mutating func push(_ item: Int) { items.append(item) }
mutating func pop() -> Int? { items.popLast() }
}
struct StringStack {
private var items: [String] = []
mutating func push(_ item: String) { items.append(item) }
mutating func pop() -> String? { items.popLast() }
}
Same logic, different types. Generics let you write it once and let the compiler do the rest.
The basic syntax
The generic type is declared inside <> after the name.
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
}
}
Element is a placeholder — a name you give to whatever type comes in. It could be T, Item, Value, anything. In Swift, T is the convention for generic cases, and descriptive names (Element, Value, Key) for when the role is clear.
In use:
var numbers = Stack<Int>()
numbers.push(1)
numbers.push(2)
numbers.pop() // 2
var words = Stack<String>()
words.push("hello")
words.push("world")
words.top // "world"
The compiler generates a version of Stack for each type you use. You wrote the code once.
Type constraints
The placeholder accepts any type by default. Sometimes you need the type to have certain capabilities — to be comparable, hashable, or to implement some protocol of yours. That’s what constraints with : are for.
struct SortedStack<Element: Comparable> {
private var items: [Element] = []
mutating func push(_ item: Element) {
items.append(item)
items.sort()
}
var top: Element? { items.last }
}
Element: Comparable tells the compiler to only accept types that implement Comparable. Without it, items.sort() wouldn’t compile — the compiler doesn’t know how to sort something of an unknown type.
This is exactly what we did with Clamped<Value: Comparable> in the property wrappers post. The constraint was necessary to be able to call min() and max().
The where clause
For more complex constraints, where lets you add them without crowding them inside the <>.
func areEqual<T>(_ a: T, _ b: T) -> Bool where T: Equatable {
a == b
}
It also works for constraining associated types from protocols. A more concrete example: a function that sums the elements of any collection, as long as its elements are numeric.
func sum<C: Collection>(_ collection: C) -> C.Element
where C.Element: Numeric {
collection.reduce(0, +)
}
sum([1, 2, 3]) // 6
sum([1.5, 2.5, 3.0]) // 7.0
Remove the where and the compiler complains: it doesn’t know whether C’s elements support addition. The constraint gives it that guarantee.
Generic functions
Generics also work in standalone functions. One I wrote before finding it in the stdlib: finding the first element that meets a condition, regardless of the type.
func first<T>(in array: [T], where condition: (T) -> Bool) -> T? {
for element in array {
if condition(element) { return element }
}
return nil
}
first(in: [1, 3, 5, 8, 9]) { $0.isMultiple(of: 2) } // 8
first(in: ["a", "bb", "ccc"]) { $0.count > 1 } // "bb"
It already exists as array.first(where:) in the stdlib. Building it first is what helped me understand how it works internally — and gave me the instinct to look for it later.
It’s normal to get here after the fact
Sometimes you have to write the same code two or three times to feel the friction. That moment when something doesn’t scale, when you’re copying and pasting and you know it’s not right, is the one that leads you to look for a better way. That’s normal, especially when you’re learning. There’s no need to optimize everything from the start — and most of the time it’s not even a good idea.
What does help is looking at how others solve it, reading well-written code, seeing real examples. That’s how you get to generics: not from a manual, but from having felt the problem first. If you have repetitive code today that you know could be better, don’t worry too much about it. Write it, make it work, and when it stops scaling, that’s the moment to dig in. Writing code you’ll later improve is part of growing as a developer — not a sign that you’re doing it wrong.