Swift Opaque Types

Jan 27, 2022iosswifttype-checking

Introduced in Swift 5.1, an opaque type defines a type that conforms to a protocol or protocol composition, without specifying the underlying concrete type. They give you the flexibility to return and preserve the identity of any type, related but different from protocols and generics.

Currently some syntax is only supported in return position, so opaque types also referred as opaque return types or opaque result types.

struct MyView: View {
  var body: some View {
    Text("Hello World!")
  }
}

Within the context of SwiftUI, opaque types are used to let us return any View expression from our body implementations, without having to specify any explicit types.

Syntax and Usage

type ::= opaque-type
opaque-type ::= 'some' constraint

The constraint is a class type, protocol type, protocol composition type, or Any. A value can be used as an instance of the opaque type only if it’s an instance of a type that conforms to the listed protocol or protocol composition, or inherits from the listed class.

protocol P { /* ... */ }
extension Int : P { /* ... */ }
extension String : P { /* ... */ }
func f() -> some P {
  return "opaque"
}

protocol Shape { }
struct GameObject {
  let strings: some Collection = ["hello", "world"]
  var shape: some Shape { /* ... */ }
  subscript(index: Int) -> some Shape { /* ... */ }
}

Opaque result types are only opaque to the static type system. They don’t have an independent existence at runtime. You can inspect an opaque type’s underlying type at runtime using dynamic casting.

func foo() -> some BinaryInteger { return 219 }
var x = foo()
let i = 912
x = i // error: Int is not known to be the same as the return type as foo()

if let x = foo() as? Int {
  print("It's an Int, \(x)\n")
} else {
  print("Guessed wrong")
}

Some Restrictions

The current implementation on opaque types prevents them from being used in many common API patterns.

An opaque type may be used as the result type of a function, the type of a variable, or the result type of a subscript. In all cases, the opaque type must be the entire type.

Opaque types can’t appear as part of a tuple type or a generic type, such as the element type of an array or the wrapped type of an optional.

Protocol declarations can’t include opaque types. Classes can’t use an opaque type as the return type of a nonfinal method.

A function that uses an opaque type as its return type must return values that share a single underlying type.

// we cannot express a function that might fail to produce an opaque result type
func f0() -> (some P)? { /* ... */ }

// we cannot use an opaque result type as one of several return values
func f1() -> (some P, some Q) { /* ... */ }

// we cannot return a lazily computed opaque result type
func f2() -> () -> some P { /* ... */ }

// more generally, we cannot embed an opaque result type into a larger structure
func f3() -> S<some P> { /* ... */ }

Reverse of Generics

Generics are Swift’s tool for type-level abstraction in function interfaces, but they work in a way that is fundamentally in the caller’s control.

func roll<T>() -> T { ... }
let x: Int = roll() // T == Int chosen by caller
let y: String = roll() // T == String chosen by caller

What if we want to abstract a return type chosen by the implementation from the caller. To achieve type-level abstraction of a return type, we would need to use opaque return type.

You can think of opaque types like being the reverse of generic types. With generic types, the caller determines the concrete type of the placeholder (outside). While with opaque types, the implementation determines the concrete type (inside).

func reverseGeneric() -> some Shape {
  // return any type conforms to Share
  return Rectangle(...)
}

// abstracted type chosen by reverseGeneric's implementation
let x = reverseGeneric()

Different from Protocols

How is opaque type different from just using a protocol? It’s all come down to what the compiler sees. With protocols, the underlying type information is hidden from you as well as the compiler. But when using opaque types, the compiler gets access to the underlying type information as well.

Using a protocol as the return type give you the flexibility to return any type that conforms to the protocol. But specific type information isn’t preserved, hence some operations like == aren’t possible on the returned values.

protocol Shape { /* ... */ }
struct Triangle: Shape { /* ... */ }
func protoFlip<T: Shape>(_ shape: T) -> Shape { /* ... */ }
let smallTriangle = Triangle(size: 3)
let protoFlippedTriangle = protoFlip(smallTriangle)
let sameThing = protoFlip(smallTriangle)
if protoFlippedTriangle == sameThing { /* ... */ }  // Error

In contrast, opaque types preserve the identity of the underlying type. Swift can infer associated types, which lets you use an opaque return value in places where a protocol type can’t be used as a return value.

The compiler knows enough about the opaque type even though it doesn’t expose the information to you at compile time it does know enough to allow you to use a protocol with an associated type as a return value.