Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 083 / 100

Generics in Swift: Swift & iOS best practices explained

Reusable, type-safe code.

Expona Admin7 min read

Generics in Swift: Swift & iOS best practices explained

We've all felt the pain of duplicating logic across different types, only to realize months later that a bug fix needs to be applied in three places. Generics solve this by letting us write functions and types that work with any data type while maintaining complete type safety. Today we're diving into how to build reusable, maintainable code that scales across large iOS applications.

Why generics matter in production code

In real-world iOS apps—whether you're building e-commerce platforms, ride-hailing services, or SaaS dashboards—you'll often find yourself writing similar logic for different types. Without generics, you'd either duplicate code, resort to Any, or create separate implementations for each type. None of those scale well.

Generics let us express intent clearly: "this function works with any type, as long as it meets these requirements." The compiler enforces those requirements at compile time, catching errors before they reach production.

Generic functions and types

Let's start with the fundamentals. A generic function accepts a type parameter (usually written as <T>) and uses it in the function signature.

swift
func logResponse<T>(_ value: T) {
    print("Received: \(value)")
}

logResponse("user_123")
logResponse(42)
logResponse([1, 2, 3])

This function works with any type because it doesn't care about the specific type—it just logs whatever arrives. But in production, we usually care more. Let's say we're building a networking layer for an e-commerce platform:

swift
struct APIResponse<T> {
    let data: T
    let statusCode: Int
    let timestamp: Date
}

struct Product {
    let id: String
    let name: String
    let price: Double
}

struct Order {
    let id: String
    let items: [Product]
    let total: Double
}

// Same response structure for different API endpoints
let productResponse = APIResponse(data: Product(id: "p1", name: "Laptop", price: 999.99), statusCode: 200, timestamp: Date())
let orderResponse = APIResponse(data: Order(id: "o1", items: [], total: 0), statusCode: 200, timestamp: Date())

Notice how we're reusing APIResponse for completely different types. This is the power of generics: one definition, infinite use cases. We avoid creating ProductResponse, OrderResponse, and a dozen other nearly-identical types.

Generic types can have multiple parameters:

swift
struct Pair<T, U> {
    let first: T
    let second: U
}

let userIdPair = Pair(first: "user_123", second: 456)
let coordPair = Pair(first: 40.7128, second: 74.0060)

Constraints with where clauses

Real-world code rarely needs "any type works here." Usually, we need the type to satisfy certain conditions. That's where constraints come in. A common pattern in our trading and CRM platforms is storing items in a cache—but only if they can be uniquely identified:

swift
struct Cache<T: Identifiable> {
    private var items: [T] = []
    
    mutating func store(_ item: T) {
        items.append(item)
    }
    
    func retrieve(by id: T.ID) -> T? {
        items.first { $0.id == id }
    }
}

struct User: Identifiable {
    let id: String
    let name: String
}

var userCache = Cache<User>()
userCache.store(User(id: "u1", name: "Alice"))

The constraint <T: Identifiable> tells the compiler: "T can be any type, but it must conform to Identifiable." This guarantees we can call .id on any item we store.

For more complex scenarios, we use where clauses:

swift
func processPayments<T>(_ transactions: [T]) where T: Codable & Identifiable {
    for transaction in transactions {
        // Can encode, decode, and access .id
        print("Processing: \(transaction.id)")
    }
}

struct Transaction: Codable, Identifiable {
    let id: String
    let amount: Double
}

We can constrain associated types too, which is especially powerful when working with collections:

swift
func filterAndSort<Collection>(_ items: Collection) -> [Collection.Element]
where Collection: Sequence, Collection.Element: Comparable {
    return items.sorted()
}

filterAndSort([3, 1, 2])
filterAndSort(["zebra", "apple", "banana"])

This function works with any collection—arrays, sets, custom sequences—as long as the elements are comparable.

some and any for opaque and existential types

Swift 5.1 introduced some (opaque types) and Swift 5.6 brought improvements to any (existential types). These are often confusing, so let's clarify with real scenarios from our SaaS platforms.

Use some when you know the concrete type, but the caller doesn't need to:

swift
protocol AnalyticsEvent {
    var eventName: String { get }
}

struct PageViewEvent: AnalyticsEvent {
    let eventName = "page_view"
    let url: String
}

struct ErrorEvent: AnalyticsEvent {
    let eventName = "error"
    let message: String
}

// Return type is opaque—caller knows it's *some* AnalyticsEvent
// but not which concrete type
func trackUserBehavior() -> some AnalyticsEvent {
    if Bool.random() {
        return PageViewEvent(url: "/dashboard")
    } else {
        return ErrorEvent(message: "Connection timeout")
    }
}

The key advantage: the compiler enforces that we always return the same concrete type from a given code path. If we later try to return different types from different branches, we get a compile error. This guarantees type safety.

Use any when you need to store or pass around different concrete types that share a protocol:

swift
// This won't compile—we don't know which type is which
let events: [AnalyticsEvent] = [
    PageViewEvent(url: "/home"),
    ErrorEvent(message: "Timeout")
]

// This works—we're saying "any type conforming to AnalyticsEvent"
let events: [any AnalyticsEvent] = [
    PageViewEvent(url: "/home"),
    ErrorEvent(message: "Timeout")
]

for event in events {
    print(event.eventName)
}

In practice, use some for function return types and any for collections or stored properties where you need runtime flexibility.

Primary associated types for protocols

When a protocol has associated types, specifying them gets verbose quickly. Primary associated types (Swift 5.7+) let callers specify the most important ones directly:

swift
protocol Repository<Model> {
    associatedtype Model
    associatedtype Error: Swift.Error
    
    func fetch(id: String) async throws(Error) -> Model
}

struct User {
    let id: String
    let email: String
}

struct UserRepository: Repository {
    typealias Model = User
    typealias Error = NetworkError
    
    func fetch(id: String) async throws(NetworkError) -> User {
        // Network call
        return User(id: id, email: "user@example.com")
    }
}

With primary associated types, we can write cleaner constraints:

swift
// Instead of this verbose syntax
func displayUser<R: Repository>(_ repo: R) where R.Model == User {
    // ...
}

// We can write this
func displayUser(_ repo: Repository<User>) {
    // ...
}

This is especially valuable in large codebases with deep dependency chains. It makes generic code more readable and easier to refactor.

Common mistakes

We've seen several patterns emerge across our projects that slow teams down:

  • Over-constraining too early: Don't add constraints unless you actually need them. Start generic and tighten only when needed. Extra constraints make code harder to reuse.
  • Mixing some and any carelessly: Remember: some hides the concrete type but guarantees it's the same every time. any lets you mix types but loses type information at the call site. Choose intentionally.
  • Ignoring performance implications of any: Protocol types with any involve runtime dynamic dispatch. In hot loops (rendering, high-frequency trading updates), profile before using.
  • Creating generic types when protocols suffice: Not every reusable pattern needs generics. If you just need shared behavior, a protocol might be enough. Generics add complexity.
  • Forgetting that generic constraints are part of the public API: When you constrain a generic type, you're promising callers that those constraints hold. Changing them later is a breaking change.

Key takeaways

  • Generics let you write type-safe, reusable code that works across different types without duplication.
  • Constraints with <T: Protocol> and where clauses ensure generics only work with types that have the capabilities you need.
  • Use some for return types when you know the concrete type internally but want to hide it; use any for collections or storage where you need runtime polymorphism.
  • Primary associated types make protocol-based generics more readable and easier to reason about in production codebases.
  • Start simple, constrain gradually, and measure performance when using any in performance-critical code paths.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles