How cursed is my Type Erasing pattern?

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 MyView usages

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>
    }
}

MyView needs 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.

If I'm understanding your question correctly, you can achieve what you need by just using the builtin SwiftUI environment:

// Using ObservableObject to match your example but Observable works just as well too

final class MyGenericModel<T>: ObservableObject {
    let value: T

    init(_ value: T) {
        self.value = value
    }
}

struct ContentView: View {
    @StateObject private var model = MyGenericModel(10)

    var body: some View {
        IntermediateView()
            .padding()
            // inject the generic object into the environment
            .environmentObject(model)
    }
}

// Intermediate views don't need to be generic
struct IntermediateView: View {
    var body: some View {
        // If the view using the object is not itself generic, we can use it directly
        NonGenericChildView()
        // If the the view using the object is generic we can hide it with a non-generic wrapper that applies the concrete type
        IntWrapperView()
    }
}

struct IntWrapperView: View {
    var body: some View {
        GenericChildView<Int>()
    }
}

struct NonGenericChildView: View {
    @EnvironmentObject private var model: MyGenericModel<Int>

    var body: some View {
        Text("\(model.value)")
    }
}

struct GenericChildView<T>: View {
    @EnvironmentObject private var model: MyGenericModel<T>

    var body: some View {
        Text("\(model.value)")
    }
}
2 Likes

Thank you for your input.

The Environment Object way still needs the intermediate view-tree either knowing the exact types of A and B their users would declare (which isn't possible) or making their view-tree generic (which is what I'm trying to solve with the type-erase).

The Intermediate Library does more things than just using my view, and hence the MyView would be layers deep in their view hierarchy.

Apologies, I was interpreting your question to mean that you knew the concrete types in the actual 'child' view and just didn't want the intermediate views to know about them as well.

As to your specific implementation, you process and duplicate the DataStore contents when creating AnyDataStore which might be a bit inefficient depending on how you use it. Having a protocol to which both DataStore and AnyDataStore conform and lazily accessing the wrapped DataStore in AnyDataStore would probably be the more standard way of doing manual type erasure.
Either way you end up with a lot of boilerplate-y code though.

One way to sidestep the issue would be to erase MyView instead of DataStore. That way you can use the type erasure provided by AnyView and don't need to roll your own if you can live with the tradeoffs created by that.

// Slightly simplified example with a similar structure to yours
protocol Item {}

struct MyItem: Item {}

final class Processor<I: Item>: ObservableObject {
    func processItem(_ item: I) { /* ... */ }
}

extension EnvironmentValues {
    @Entry var makeSpecializedItemView: (any Item) -> AnyView = { _ in fatalError() }
}

struct ContentView: View {
    @StateObject private var processor = Processor<MyItem>()

    var body: some View {
        IntermediateView()
            .padding()
            .environmentObject(processor)
            .environment(\.makeSpecializedItemView) {
                AnyView(ItemView(item: $0 as! MyItem))
            }
    }
}

struct IntermediateView: View {
    // Type erased input for child view, we don't know the concrete type
    private let item: any Item = MyItem()

    var body: some View {
        AnyItemView(item: item)
    }
}

struct AnyItemView: View {
    let item: any Item

    @Environment(\.makeSpecializedItemView) private var makeSpecializedItemView

    // Helper to open the item existential, create a ItemView for the actual type and return a type-erased AnyView
    func makeItemView<I: Item>(_ item: I) -> AnyView {
        AnyView(ItemView(item: item))
    }

    var body: some View {
        // Opening the existential will work work with any possible Item type but will generally use an unspecialized ItemView implementation.
        makeItemView(item)

        // We might also provide a specialized version from some other location if that knows the concrete item type
        // that will be used here. Using the environment here but could of course also be stored in some observed
        // object or be passed into the view along with the item itself, ...
        makeSpecializedItemView(item)
    }
}

struct ItemView<I: Item>: View {
    let item: I

    @EnvironmentObject private var processor: Processor<I>

    var body: some View {
        // ...
    }
}
1 Like

Thank you Jnosh, and no need for apologies :sweat_smile:, This problem is pretty contrived and (I think) niche, So almost everyone would be confused on why am I doing this.

And yes, the duplication of contents on AnyDataStore is the sore thumb of this ordeal. Your point on lazily accessing the elements is an neat idea and I am going to try this, along with Type-Erasure of the view. More specifically, I need to transfer the type-erased view builder to DataStore itself or other accompanying wrapper since intermediate library would get multiple datastores whose items are different types. Thank you for the ideas!