Hi, I am developing an library to be used internally in our team.
I currently have an Type Erasing system that works. But I am having doubts about its maintainability since it feels awkward to even look at and I couldn’t find any examples on pattern of type-erasing I follow (I completely came up this bs).
First I have an ObservableObject class with Generic Requirements:
class DataStore<A: AThing, B: Sendable>: ObservableObject {
@Published private(set) var arrayOfAThings: [A] = []
@Published private(set) var arrayOfB: [B] = []
func modify(arrayOfAThings: [A], arrayOfB: [B]) {
self.arrayOfAThings = arrayOfAThings
self.arrayOfB = arrayOfB
}
}
which is used by my view as Source of Truth: (View needs an Concrete Type, not existensials)
@StateObject var datastore: DataStore<CustomA, CustomB> = .init()
MyView(datastore: datastore) // MyView<CustomA, CustomB>
It is all fine and dainty when the object can be provided to the view directly. But when there should be intermediate views (say views from another package that need to use my view but need to pass the data from their users to mine), the Generic Requirement has to be propagated til MyView is used directly.
Example:
Use cases vary dramatically, like storing an list of datastores or deep hierarchy
MyViewusages
struct IntermediateView<A: AThing, B>: View {
let datastore: DataStore<A, B>
var body: some View {
MyView(datastore: datastore)
}
}
struct ParentView: View {
@StateObject var datastore: DataStore<CustomA, CustomB> = .init()
var body: some View {
IntermediateView(datastore: datastore)
}
}
However the intermediates wouldn't have any use with exact type of the A and B, hence the case for type erasure.
This is my current typeerasing pattern:
class AnyDataStore: DataStore<AnyAThing, AnyB> {
// Just so the users can get the original instance by typecasting this.
let base: AnyObject
private var wrappedModify: ([AnyAThing], [AnyB]) -> Void
init<A: AThing, B: Sendable>(base: DataStore<A, B>) {
self.base = base
self.wrappedModify = { erasedAArray, erasedBArray in
base.modify(
arrayOfAThings: erasedAArray.typecast(to: A.self), // helper function
arrayOfB: erasedBArray.typecast(to: B.self) // helper function
)
}
super.init()
base.$arrayOfAThings
.map{ $0.eraseToAnyAThing() } // Erases the element in the array -> [AnyAThing]
.assign(to: &Self.$arrayOfAThings)
base.$arrayOfB
.map { $0.eraseToB() }
.assign(to: &Self.$arrayOfB)
}
override func modify(arrayOfAThings: [AnyAThing], arrayOfB: [AnyB]) {
wrappedModify(arrayOfAThings, arrayOfB)
}
}
extension DataStore {
func eraseToAnyDataStore() -> AnyDataStore {
.init(base: self)
}
}
Now, the intermediates do not need to have generics in their signature and the MyView can be used by since there is an typeerased concrete type:
struct ParentView: View {
@StateObject var datastore: DataStore<CustomA, CustomB> = .init()
var body: some View {
IntermediateView(datastore: datastore.eraseToAnyDataStore())
}
}
struct IntermediateView: View {
@ObservedObject var datastore: AnyDataStore
var body: some View {
MyView(datastore: datastore) // MyView<AnyAThing, AnyB>
}
}
MyViewneeds an Concrete type but that needn't to be an exact one.
Now why this thing need an Concrete type you may ask?, well, when directly using the
view there are callbacks to do certain things that gives the element to the user, hence it being the element type they gave would make it far simpler to work with.