Using an Instantiator with a protocol causes a build failure
MrAdamBoyd opened this issue · comments
I am using the example project integration in the codebase.
- Open
NoteView.swift
- Change
any UserService
type toInstantiator<any UserService>
- Build error:
Type 'any UserService' does not conform to protocol 'Instantiable'
Sample code or sample project upload
@MainActor
@Instantiable
public struct NoteView: Instantiable, View {
public init(userName: String, userServiceBuilder: Instantiator<any UserService>, stringStorage: StringStorage) {
self.userName = userName
self.userServiceBuilder = userServiceBuilder
self.stringStorage = stringStorage
_note = State(initialValue: stringStorage.string(forKey: userName) ?? "")
}
public var body: some View {
VStack {
Text("\(userName)’s note")
TextEditor(text: $note)
.onChange(of: note) { _, newValue in
stringStorage.setString(newValue, forKey: userName)
}
Button(action: {
userService.userName = nil
}, label: {
Text("Log out")
})
}
.padding()
}
@Forwarded
private let userName: String
@Instantiated
private let userServiceBuilder: Instantiator<any UserService>
@Received
private let stringStorage: StringStorage
private lazy var userService = userServiceBuilder.instantiate()
@State
private var note: String = ""
}
#Preview {
NoteView(
userName: "dfed",
userService: DefaultUserService(stringStorage: UserDefaults.standard),
stringStorage: UserDefaults.standard
)
}
Yeah, that makes sense, and is unfortunate. Today returning existentials (protocols without concrete types) requires utilizing ErasedInstantiator
since, well... you are erasing the type.
This is a bit annoying, but there's some good news: SafeDI can rename a property! So you can do something like:
public protocol ErasedType {}
@Instantiable
public final class ConcreteType: ErasedType, Instantiable {
... // no forwarded properties here for this example
}
public typealias ErasedTypeInstantiator = ErasedInstantiator<(), ErasedType>
@Instantiable
public final class ParentType: Instantiable {
@Instantiated(fulfilledByType: "ConcreteType") private let erasedTypeErasedBuilder: ErasedInstantiator<(), ErasedType>
@Received(fulfilledByDependencyNamed: "erasedTypeErasedBuilder", ofType: ErasedInstantiator<(), ErasedType>.self) private let erasedTypeBuilder: ErasedTypeInstantiator
}
I haven't tested that... but I think it might work.
However, we're kinda working around both SafeDI and the Swift type system with that approach. Our problem here is that we've got an existential without a concrete type. So... let's make a concrete type!
public protocol ErasedType {
// some API here
}
@Instantiable
public final class ConcreteType: ErasedType, Instantiable {
... // no forwarded properties here for this example
}
@Instantiable
public final class AnyErasedType: ErasedType, Instantiable {
/// An initializer that you can use for your own unit testing.
public init(_ erasedType: any ErasedType) {
self.erasedType = erasedType
}
/// An initializer that SafeDI uses.
public init(/* take all DI arguments here */) {
erasedType = ConcreteType(/* forward your DI arguments here */)
}
// ErasedType API here, deferring all calls to `erasedType`'s implementation.
private let erasedType: any ErasedType
}
@Instantiable
public final class ParentType: Instantiable {
@Instantiated private let erasedTypeBuilder: Instantiator<AnyErasedType>
}
And now you have a concrete existential, so it compiles! Type erasure to the rescue!
Let me know which approach you like. Maybe we can take a task to document this better in the README. But for now, I'm gonna close this out since we have a few options on the table.