[Pitch] Opaque Conformance for Protocols

@aetherealtech Thank you for response! I wanted to avoid potential suggestions, such as using enum containers, boxed types or Any instead of protocol instances. This is why I’ve framed this example using two sealed protocols instead of one.

Example: Library with Sealed Protocols

Objectives:

  • Library should support a fixed set of foundational types (e.g. Elements and Operations).
  • Design should enable agile and safe development of new features: adding new Operations and Types
  • Library should not expose implementation details through the API

Type Declarations:

  • Defines the Element protocol and restricts conformances to specific types within the Library.
  • Implements (lets say, large) set of processor types that conform to Operation protocol, with internal-level access for methods.
// MyLibrary

public sealed protocol Element {
    // Accessible only within the Module
    internal func processElement() -> String
}

extension Int: Element {
    internal func processElement() -> String {
        return "Processing Int: \(self)"
    }
}

extension String: Element {
    internal func processElement() -> String {
        return "Processing String: \(self)"
    }
}

extension Data: Element {
    internal func processElement() -> String {
        return "Processing Data of length: \(self.count)"
    }
}

public struct Message: Element {
    internal func processElement() -> String {
        return "Processing Message"
    }
}

public sealed protocol Operation {
    // Accessible only within the Module
    internal func internalOperationA(_ element: Element)
    internal func internalOperationB(_ element: Element) -> Element
    internal func internalOperationC(_ element: Element, secret: MySecret) -> Element
}

public struct ProcessorA: Operation {
    internal func internalOperationA(_ element: Element) {
        print("ProcessorA - Internal A: \(element.processElement())")
    }
    
    internal func internalOperationB(_ element: Element) -> Element {
        print("ProcessorA - Internal B: \(element.processElement())")
        return element
    }

    internal func internalOperationC(_ element: Element, secret: MySecret) -> Element {
        print("ProcessorA - Internal C: \(element.processElement())")
        return element
    }
}

public struct ProcessorB: Operation {
    internal func internalOperationA(_ element: Element) {
        print("ProcessorB - Internal A: \(element.processElement())")
    }
    
    internal func internalOperationB(_ element: Element) -> Element {
        print("ProcessorB - Internal B: \(element.processElement())")
        return element
    }

    internal func internalOperationC(_ element: Element, secret: MySecret) -> Element {
        print("ProcessorB - Internal C: \(element.processElement())")
        return element
    }
}

Note : Allowing users to implement Operation can leak internal types and instances, as it happens with internalOperationC method declared above. Regular protocols require internal types like MySecret to be public, and then its instance can be exposed to the Uninvited Visitor. Situation becomes more complicated, if such types conform to some protocols, like Codable -- conformance is also publicly exposed.

User-Facing API:

public struct UserDataHandlerA {
    private let processor: Operation

    public init(processor: Operation) {
        self.processor = processor
    }

    public func handleData(with elements: [any Element]) -> [any Element] {
        return elements.map { processor.internalOperationB($0) }
    }
}

public struct UserDataHandlerB {
    private let processor: Operation

    public init(processor: Operation) {
        self.processor = processor
    }

    public func handleData(with elements: [any Element]) -> [any Element] {
        return elements.map { processor.internalOperationB($0) }
    }
}

Usage form Clients Application:

  • Uses UserDataHandlerA with ProcessorA for initial processing.
  • Passes the result to UserDataHandlerB with ProcessorB for further processing.
// Hypothetical Clients Application

import MyLibrary

let handlerA = UserDataHandlerA(processor: ProcessorA())
let handlerB = UserDataHandlerB(processor: ProcessorB())
let initialElements: [any Element] = [1, "string", Data(), Message()]

// First processing pass with handlerA
let processedElements = handlerA.handleData(with: initialElements)

// Second processing pass with handlerB using the results from handlerA
let finalElements = handlerB.handleData(with: processedElements)

Alternatives to Sealed Protocols:

There are several alternatives of how to hide implementation details and internal types from the Public API.

  • Enum Containers: Instead of protocols, we can use enum containers for types supported by MyLibrary.
  • Boxed Types: We can declare single-element shells for types supported by MyLibrary and conform them to a common public protocol.

Both approaches:

  • Complicate implementation and in my opinion, do not meet requirement of agile development, as adding new types and operations becomes quite complex.
  • Require constant back-and-forth transformation between public representation and backed internal types, which is especially unfortunate if types are wrapped into complex Collections.

Instead of summary

I’m quite sure there could be more suggestions on how to achieve my objectives. However, the approach in this example works perfectly with regular protocols when these objectives aren’t required. It feels counterintuitive that I need to use workarounds to achieve these goals, rather than simply annotating my protocols.

1 Like