Skip to Content
Course content

183: Recursive Generic Constraints

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I was working on a system recently where I needed to combine data from two different sources—think of it like merging two user profiles or two configuration files. My first instinct was to create a simple protocol to handle this. I figured, if something is "mergeable," it should just take another mergeable thing and return the result.

The "Too Generic" Trap

Here is how I started. I defined a protocol called Mergeable and gave it a method that takes another Mergeable object.

protocol Mergeable {
    func merge(with other: Mergeable) -> Mergeable
}

struct UserProfile: Mergeable {
    var name: String
    var points: Int

    func merge(with other: Mergeable) -> Mergeable {
        // I have a problem here...
        return self 
    }
}

As soon as I started implementing this in UserProfile, I hit a wall. Inside the merge function, the other parameter is typed as Mergeable, not UserProfile. To actually access other.points, I'd have to force-cast it using as? UserProfile. That's clumsy, it's error-prone, and it completely defeats the purpose of using generics.

Even worse, nothing stops me from accidentally trying to merge a UserProfile with a SystemSettings object, as long as both conform to Mergeable. That's a recipe for a runtime crash.

Introducing the Recursive Constraint

I need the compiler to guarantee that other is the exact same type as the instance currently calling the method. In Swift, we do this using the Self keyword within the protocol definition. This is what we call a recursive generic constraint because the protocol refers back to the type that eventually adopts it.

Let's adjust the protocol:

protocol Mergeable {
    func merge(with other: Self) -> Self
}

Now, look what happens when I implement it in UserProfile. I don't need to cast anything anymore:

struct UserProfile: Mergeable {
    var name: String
    var points: Int

    func merge(with other: UserProfile) -> UserProfile {
        return UserProfile(
            name: self.name, 
            points: self.points + other.points
        )
    }
}

The compiler now understands that if you are calling merge on a UserProfile, the argument must be another UserProfile. If I try to pass a different Mergeable type in there, the code won't even compile. This is much safer.

The Generic Function Wall

Everything seemed great until I tried to write a helper function to merge a list of these items. I tried this:

func mergeAll<T: Mergeable>(items: [T]) -> T? {
    guard var result = items.first else { return nil }
    
    for item in items.dropFirst() {
        result = result.merge(with: item)
    }
    
    return result
}

Surprisingly, this works perfectly. But here is where you'll likely get tripped up: what happens if you try to use Mergeable as a type itself? If you try to create an array of [Mergeable], Swift will throw a compiler error: "Protocol 'Mergeable' can only be used as a generic constraint because it has 'Self' requirements."

This is the trade-off. By using Self, you've made the protocol incredibly powerful for generic functions (like mergeAll), but you've lost the ability to use the protocol as a standalone type. You can't have a variable of type Mergeable because the compiler can't know which specific version of Self it's dealing with at runtime.

Why this matters for your architecture

I've seen a lot of developers fight this error for hours. The key is to realize that recursive constraints are for behavioral contracts, not for type erasure. Use them when you need to ensure that two objects interacting are of the same type—like in Comparable (where you compare Self to Self) or in our Mergeable example.

If you find yourself needing an array of different mergeable types, you'll need a different pattern (like a wrapper or a type-erased box), but for strict, type-safe operations, the recursive Self constraint is your best tool.




📋 Practical Task

Build a Type-Safe Coordinate Blender

You are building a graphics engine that needs to "blend" two coordinates of the same type (e.g., two 2D points or two 3D points) by averaging their values.

  • Create a protocol named Blendable. It should have a recursive generic constraint requiring a method blend(with other: Self) -> Self.
  • Implement this protocol in a struct called Point2D (containing x and y as Doubles). The blend method should return a new Point2D where the coordinates are the average of the two points.
  • Implement this protocol in a struct called Point3D (containing x, y, and z as Doubles) using the same averaging logic.
  • Write a generic function called blendSequence<T: Blendable>(points: [T]) -> T? that takes an array of blendable items and iteratively blends them all into one final result.
  • Test your code by blending an array of Point2D objects and ensuring the compiler prevents you from blending a Point2D with a Point3D.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.