Swift & Objective-C

[Intermediate Swift #2] Eliminating Duplication and Risk with Generics

The first time you encounter angle brackets <T> in Swift code, you pause. A capital letter appears beside a function name, and opening the documentation reveals signatures like func map<T>( transform: (Element) -> T) -> [T]. Generics are the gateway to intermediate Swift…

6 min read
Cover image for [Intermediate Swift #2] Eliminating Duplication and Risk with Generics

The first time you encounter angle brackets <T> in Swift code, you pause. A capital letter appears beside a function name, and opening the documentation reveals signatures like func map<T>(_ transform: (Element) -> T) -> [T]. Generics are the gateway to intermediate Swift. Nearly everything in the standard library, including Array, Dictionary, and Optional, is built with generics, so libraries start becoming readable only after you clear this hurdle.

This is part 2 of the intermediate series. We’ll cover what problems generics solve, when constraints and where clauses are needed, and what their performance looks like.

The Problems Generics Solve — Rejecting the False Choice Between Duplication and Type Safety

The problem becomes obvious when you try to write a “function that swaps two values” in a world without generics.

An Int version cannot be used with String. Copying it for every type increases duplication. The alternative is a “box that accepts any type”—Any in Swift. But Any erases type information. Every extraction requires casting, and putting in an Int and taking out a String can pass compilation and crash at runtime.

In short, the only choices were “safe but duplicated per-type copies” and “non-duplicated but risky Any.” Generics reject this trade-off.

func swapValues<T>(_ a: inout T, _ b: inout T) {
    let temp = a
    a = b
    b = temp
}

<T> declares an “empty slot to fill with a type later.” At the call site, T becomes a concrete type, and the compiler checks everything using that type. A call with mismatched types, such as swapValues(&intA, &strB), is a compile-time error. One copy of the code, with type checking performed per type—that is the essence of generics.

The same applies to types. If you declare struct Stack<Element>, then Stack<Int> and Stack<String> become different types, and the compiler prevents you from putting a string into an Int stack. Array has exactly this structure, and Optional, as we saw in the optionals article, was also the generic enum enum Optional<Wrapped>. In other words, you have already been using generics every day.

Constraints — From “Any Type” to “A Type with These Capabilities”

The default state of the blank T is any type. But “any type” also means you can do nothing with it. Since the compiler knows nothing about T, you cannot compare, print, or add values.

func largest<T>(_ items: [T]) -> T? {
    items.max(by: <)  // Compile error — Tthere is no guarantee it is comparable
}

This is where constraints enter. Writing <T: Comparable> adds the condition that “T may only be a type conforming to Comparable,” which lets you use < with T.

func largest<T: Comparable>(_ items: [T]) -> T? {
    items.max()
}

A constraint is a trade, not a loss. You narrow the range of accepted types, but gain more operations for those types. This is where the protocol-side concept of “combining capabilities” meets generics, because protocols are what constraints use.

When conditions become complex, use a where clause. The meaning is the same; only the position changes. But when constraining an associated type of a type parameter, where becomes mandatory.

// Elementonly compare collections whose Equatableare
func allEqual<C: Collection>(_ items: C) -> Bool where C.Element: Equatable {
    guard let first = items.first else { return true }
    return items.allSatisfy { $0 == first }
}

The type of the elements contained by a collection is its associated type, as in C.Element. We’ll cover this in depth in the article after next, so for now, remember only that the where clause is where this condition is imposed.

One practical guideline: apply only the constraints you need. Requiring Hashable from a function that only needs Comparable unnecessarily reduces the types it can accept. The exact list of capabilities used in the function body is the right constraint list.

Constraints narrow the door but expand what you can do inside
Constraints narrow the door but expand what you can do inside

Performance — The Compiler Erases the Cost of Abstraction

When someone asks, “Aren’t generics slow?”, Swift’s signature optimization provides the answer: specialization.

In principle, a generic function does not know which type will arrive, so it must carry type information at runtime and operate indirectly. But when the compiler can see the call site, it emits a separate version specifically for swapValues<Int>. The resulting machine code is the same as if you had written the Int version by hand. Using the generic abstraction does not impose runtime cost; the compiler strips it away and produces concrete code. It is a representative example of zero-cost abstraction, as discussed in part 1 of the philosophy series.

This optimization works well within the same module, while crossing module boundaries introduces limitations depending on how the library is distributed. The practical conclusion is that there is no reason to avoid generics in everyday code out of performance concerns; measure to find the real bottleneck. We covered premature optimization in a separate article.

A natural question follows: “If you accept a protocol type, such as items: [Comparable], how is that different from a generic?” Good question—and it is the subject of the next article. Generics provide static polymorphism, with the type determined at compile time; protocol types (existentials) provide dynamic polymorphism, with types mixed at runtime. We’ll continue with why Swift made the distinction explicit through the some and any keywords.

When Should You Create a Generic? Practical Criteria

Reading generics and designing them yourself are different skills, so here are the criteria for creating one.

When logic like ** appears a second time with only the type changed, that is the signal.** Starting with generics because “another type might arrive someday” is usually overengineering. Follow YAGNI (You Aren’t Gonna Need It): start with a concrete type and generalize when real duplication appears.

fit structures and algorithms better than domain concepts. Caches, pagination responses, and stacks—structures independent of their contents—are generic’s home ground. Like APIResponse<User> and Cache<ImageKey, UIImage>. Conversely, forcing domain logic such as order payments into generics only makes signatures harder to understand.

is a signal to back off when signature complexity exceeds the benefit to callers. Once you have three or four type parameters and a three-line where clause, ask whether a teammate can still read the call site. The Progressive Disclosure principle applies here too: declarations should absorb complexity; it must not leak into usage.

Specialization strips away abstraction and produces the same machine code as hand-written code
Specialization strips away abstraction and produces the same machine code as hand-written code

Summary

  • Generics reject the trade-off between reusable code without duplication and type safety. There is one copy of the code, while type checking occurs per type.
  • <T>declares a type placeholder, while constraints (T: Comparable and where clauses) trade a narrower placeholder for more capabilities.
  • Thanks to specialization, generics usually perform like concrete code written by hand.
  • Design criteria: generalize when the second duplicate appears, use generics for structures and algorithms, and stop when signature complexity outweighs the benefit.

The next article covers generics’ siblings and the Swift syntax that causes the most confusion lately: some and any. We’ll also dig into what some View is and the cost of existentials.