Blog / October 15, 2024 / 7 mins read / By Mahi Garg

Opaque in Swift

Swift is known for its powerful type system and support for generics, which allows you to write flexible and reusable code. However, there are situations where generics can become overly complex or expose too much internal detail. To help with this, Swift introduced opaque types (using the some keyword) in Swift 5.1. Opaque types allow us to hide the underlying type while still maintaining strong type safety.

In this blog, we’ll explore what opaque types are, how they differ from protocols with associated types and existentials, and look at practical examples where using some can simplify code.

What is an Opaque Type?

An opaque type in Swift is a way to hide the exact type returned by a function or property while still retaining type information for the compiler. This allows the caller to know the type exists and is the same across invocations but doesn’t reveal the specific type. It is often used with the some keyword, which specifies that the returned value conforms to a particular protocol but keeps the concrete type hidden.

Here’s a simple example of an opaque type:

func makeOpaqueShape() -> some Shape {
    return Circle(radius: 5)
}

In this case, the function makeOpaqueShape returns some type that conforms to the Shape protocol, but the caller does not need to know the specific type (Circle in this case). All the caller knows is that the result conforms to the Shape protocol.

Why Use Opaque Types?

Opaque types are useful when:

  • You want to hide the implementation details of a type but still maintain type safety.
  • You want to avoid exposing complex or unnecessary type information to the caller.
  • You want to avoid the performance overhead of boxing types (like with protocol existentials).
  • You want a simple alternative to returning types with generics, without making your code overly complex.

Opaque types offer several advantages over using protocol types (also known as existentials), especially when you need to preserve the concrete type for operations like comparisons or optimizations that rely on the specific type.

Opaque Types vs. Protocols with Associated Types

Opaque types are different from protocol existentials, especially in cases where associated types or Self requirements are involved. Let’s clarify the distinction with an example.

Protocol Existentials Example

Suppose you have a protocol:

protocol Shape {
    func area() -> Double
}

And you want to return something that conforms to this protocol:

func makeShape() -> Shape {
    return Circle(radius: 5)
}

This works because Circle conforms to Shape. However, the type information is erased, meaning you lose the concrete type and only know that it conforms to Shape. If we want to keep the exact type hidden while ensuring the return type is consistent across invocations, we can use some (an opaque type).

Opaque Type Example
func makeOpaqueShape() -> some Shape {
    return Circle(radius: 5)
}

The key difference here is that with some Shape, the caller still knows the return type conforms to Shape, but the compiler also knows that the return type will always be the same specific type across different invocations. In contrast, the existential version (Shape) can return any conforming type, and you lose the specific type information.

Practical Example: Geometric Shapes

Let’s see how this works with a more detailed example involving geometric shapes.

Step 1: Define a Protocol

We’ll define a Shape protocol that requires a method to compute the area of the shape:

protocol Shape {
    func area() -> Double
}
Step 2: Create Concrete Shape Types

Now, we’ll create two concrete shapes that conform to Shape:

struct Circle: Shape {
    let radius: Double
    
    func area() -> Double {
        return .pi * radius * radius
    }
}

struct Rectangle: Shape {
    let width: Double
    let height: Double
    
    func area() -> Double {
        return width * height
    }
}
Step 3: Opaque Return Types

We can now create functions that return some Shape (opaque types). These functions will hide the specific type of shape but guarantee that the type conforms to Shape.

func makeCircle() -> some Shape {
    return Circle(radius: 10)
}

func makeRectangle() -> some Shape {
    return Rectangle(width: 10, height: 20)
}

Here’s how you would use these functions:

let shape1 = makeCircle()
print("Area of circle: \(shape1.area())")  // Output: Area of circle: 314.159...

let shape2 = makeRectangle()
print("Area of rectangle: \(shape2.area())")  // Output: Area of rectangle: 200.0

The important point here is that we can perform operations on the returned shape (shape1 and shape2) knowing they conform to the Shape protocol, but we don’t know the exact underlying type unless we examine the implementation of the function.

Opaque Types in SwiftUI

SwiftUI, Apple’s declarative UI framework, extensively uses opaque types. For example, the some View return type is commonly used in SwiftUI to hide the complex underlying view hierarchy while ensuring type safety.

Here’s a simple SwiftUI example:

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, World!")
            Button(action: {
                print("Button tapped")
            }) {
                Text("Tap me")
            }
        }
    }
}

In the ContentView struct, the body property returns some View. This indicates that the returned view will conform to the View protocol, but SwiftUI’s internal view composition details are hidden. This makes SwiftUI more efficient and less complex for developers to work with, without exposing the underlying types of each view.

Opaque Types vs. Generics

Opaque types and generics both serve similar purposes in Swift but are used in slightly different contexts.

Generic Function Example A generic function that works with any Shape might look like this:

func printArea<T: Shape>(of shape: T) {
    print("Area: \(shape.area())")
}

This function can accept any type that conforms to the Shape protocol:

let circle = Circle(radius: 5)
printArea(of: circle)  // Output: Area: 78.539...

let rectangle = Rectangle(width: 5, height: 10)
printArea(of: rectangle)  // Output: Area: 50.0

Generics are powerful and flexible, but in some cases, you don’t need the complexity of generics when returning from a function. If you simply want to return a type that conforms to a protocol but don’t need to make it generic, opaque types are a cleaner alternative.

Opaque Type vs. Generic Return Type

With a generic return type, you would need to do this:

func makeGenericShape<T: Shape>(_ type: T.Type) -> T {
    if type == Circle.self {
        return Circle(radius: 5) as! T
    } else {
        return Rectangle(width: 10, height: 20) as! T
    }
}

This can be cumbersome compared to the simplicity of using an opaque return type:

func makeOpaqueShape() -> some Shape {
    return Circle(radius: 5)
}

Limitations of Opaque Types

While opaque types are incredibly useful, they do come with some limitations:

  1. Single Return Type: Opaque types must return the same concrete type for every execution of the function. You cannot return different types based on runtime conditions.
// This will NOT compile because Circle and Rectangle are different types.
func makeShape() -> some Shape {
    if someCondition {
        return Circle(radius: 5)
    } else {
        return Rectangle(width: 10, height: 20)
    }
}
  1. Cannot be Type-Casted: Since the specific type is hidden, you cannot cast an opaque type to another concrete type:
let shape = makeOpaqueShape()
let circle = shape as? Circle  // This will result in a compile error
  1. Limited to Return Types: You can only use some for return types, not for parameters or stored properties.

Conclusion

Opaque types (some) provide a powerful way to hide complex or unnecessary type details while maintaining strong type safety in Swift. They are especially useful when you want to return a value that conforms to a protocol but don’t want to expose the concrete type to the caller. Opaque types simplify code, enhance abstraction, and are widely used in frameworks like SwiftUI.

By leveraging opaque types, you can write cleaner, more concise code while still taking full advantage of Swift’s type system.

Comments