@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
withProcessorA
for initial processing. - Passes the result to
UserDataHandlerB
withProcessorB
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.