Dilemma about enum with associated values and protocols

I can't resolve the dilemma of whether to use enums with associated values, or to use protocols, for cases where types are loosely related and there will be little or no constraints in the protocol itself. The important thing is not how to store it, but how to use it.

Suppose we have several types that have completely different properties

struct TextOptions {
    // ...
}

struct PhotoOptions {
    // ...
}

struct VideoOptions {
    // ...
}

// etc

The task arises, to store them, but this is not the most important, but the main thing is to have a strictly typed list of all such types and the ability to find out which type is which and to access a concrete type.

The most obvious approach, and what I often see in Rust, is an enum with associated values

enum AnyOptions {
    case .text(TextOptions)
    case .photo(PhotoOptions)
    case .video(VideoOptions)
    // etc
}

This approach pretty much meets all my requirements, but the problem is that I don't like it and as far as I can tell Apple's frameworks are not used for this kind of thing.

So we come to the next approach with protocols, and I honestly don't know how to implement it properly.

We start with a protocol

protocol Options {
    init?(_ options: borrowing some Options)
}

extension Options {
   init?(_ options: borrowing some Options) {
       if let anyOptions = _specialize(options, for: AnyOptions.self), let selfOptions = anyOptions.wrappedValue as? Self {
            self = selfOptions
        } else if let selfOptions = _specialize(options, for: Self.self) {
            self = selfOptions
        } else {
            return nil
        }
   }
}

We create something like

struct AnyOptions: Options {
    let wrapped: any Options

    func assumingType<T: Options>(_ type: T.Type) -> T {
        guard let options = T(self) else {
            preconditionFailure()
        }
        return options
    }

    init(_ wrapped:  some Options) {
        self.wrapped = wrapped 
    }
}

Now this can be used something like this

let anyOptions = AnyOptions(VideoOptions(...))
let videoOptions = anyOptions.assumingType(VideOptions.self)

But it's not clear to me how to find out the list of available types? Put an additional property enum OptionsType in the protocol? More questions than answers.

P.S.

1 Like

A crucial bit of information for picking the right answer is how you use these options. Are you initializing a type with them? Storing them in a dictionary? Are they arguments you pass around to a related set of functions, like locales in C? Each of these questions has an effect on which solution is best.

2 Likes

The task is as simple as possible, I want to be able to store such a value (but this is actually easy to do in today's swift), I want to have a strictly typed way to know all the types implementing the protocol (although it will work only inside one module) and most importantly, I want to know the possible type inside, quickly get a specific type to work with it.

That doesn't help me give you any advice. What are you using these options for?

What work are you doing with it?

It's simple to access the stored properties. Say, to customize a UIView based on them. And I don't want to pass the UIView instance.

Can you please define this further?

  1. Is the list in the code completion dialog not satisfactory? If not, why not? (This, combined with a documentation catalog, is Apple's approach.)
  2. What is "find out which type is which"?
let anyOptions = AnyOptions(VideoOptions())
if anyOptions.kind == .video {
    let videoOptions = anyOptions.assumingType(VideoOptions.self)
    // work with videoOptions
}


struct AnyOptions: Options {
    let options: any Options

    var kind: OptionsKind { options.kind }

    func assumingType<T: Options>(_ type: T.Type) -> T {
        T(self)!
    }

    init(_ options: some Options) {
        self.options = options
    }
}

struct VideoOptions: Options {
    var kind: OptionsKind { .video }
}

enum OptionsKind {
    case image
    case video
}

protocol Options {
    var kind: OptionsKind { get }
    init?(_ options: borrowing some Options)
}

extension Options {
    init?(_ options: borrowing some Options) {
        if let anyOptions = _specialize(options, for: AnyOptions.self), let selfOptions = anyOptions.options as? Self {
            self = selfOptions
        } else if let selfOptions = _specialize(options, for: Self.self) {
            self = selfOptions
        } else {
            return nil
        }
    }
}

No, no, not code. The wording. The phrases including "list" and "find out". What those words mean is not clear.

I don't know how else to write. When we have enum I already know the whole list of types, when I use the protocol approach I have no way (except to enter OptionsKind) to know what type is stored inside AnyOptions. So when I look at OptionsKind I see all the types that implement the protocol. This solution works, but I don't know if it is correct.

No, you see whatever cases you put there. There is no way to enforce sync with types.

What you're looking for is probably the visitor pattern.

protocol Option {
    func acceptVisitor<V: OptionsVisitor>(visitor: V) -> V.Result
}

struct TextOption: Option {
    let textValue: String
    
    func acceptVisitor<V>(visitor: V) -> V.Result where V : OptionsVisitor {
        return visitor.handleText(option: self)
    }
}

struct VideoOption: Option {
    let videoDuration: TimeInterval
    
    func acceptVisitor<V>(visitor: V) -> V.Result where V : OptionsVisitor {
        return visitor.handleVideo(option: self)
    }
}

protocol OptionsVisitor {
    associatedtype Result = Void
    func handleText(option: TextOption) -> Result
    func handleVideo(option: VideoOption) -> Result
}


struct OptionPrinter: OptionsVisitor {
    func handleText(option: TextOption) {
        print("text option: \(option.textValue)")
    }
    
    func handleVideo(option: VideoOption) -> () {
        print("video option: \(option.videoDuration)")
    }
}

func printOptions(options: [any Option]) {
    let printer = OptionPrinter()
    options.forEach({ $0.acceptVisitor(visitor: printer )})
}

func test() {
    let options: [any Option] = [TextOption(textValue: "text"), VideoOption(videoDuration: 5.0)]
    printOptions(options: options)
}
3 Likes

This approach pretty much meets all my requirements, but the problem is that I don't like it and as far as I can tell Apple's frameworks are not used for this kind of thing.

I found this reasoning not quite convincing. enum is a good choice (with a bit cumbersome syntax on the use site but that aside).

1 Like

Both methods will do what you want. The enum is itself a (hard-coded) "list of all such types", and you can add computed properties that handle each concrete type internally. Here's a starting point for this approach (typed out in haste and untested, but the principle should be clear enough):

import SwiftUI

// Define option types
struct TextOptions {
    var text: String
}

struct PhotoOptions {
    var photoName: String
}

struct VideoOptions {
    var videoURL: URL
}

// The general-purpose enum
enum AnyOptions {
    case text(TextOptions)
    case photo(PhotoOptions)
    case video(VideoOptions)
    
    // Return the appropriate subview
    @ViewBuilder
    var view: some View {
        switch self {
        case .text(let options):
            TextOptionsView(options: options)
        case .photo(let options):
            PhotoOptionsView(options: options)
        case .video(let options):
            VideoOptionsView(options: options)
        }
    }
}

// Container view
struct OptionsContainerView: View {
    @Binding var anyOptions: AnyOptions
    
    var body: some View {
        VStack {
            anyOptions.view
        }
        .padding()
        .border(Color.gray, width: 1)
    }
}

// Subviews
struct TextOptionsView: View {
    @Binding var options: TextOptions
    
    var body: some View {
        VStack {
            Text("Text Options")
            TextField("Enter text", text: $options.text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
        }
    }
}

struct PhotoOptionsView: View {
    @Binding var options: PhotoOptions
    
    var body: some View {
        VStack {
            Text("Photo Options")
            Text("Photo Name: \(options.photoName)")
        }
    }
}

struct VideoOptionsView: View {
    @Binding var options: VideoOptions
    
    var body: some View {
        VStack {
            Text("Video Options")
            Text("Video URL: \(options.videoURL.absoluteString)")
        }
    }
}



// Use them like this:
struct ContentView: View {
    @State private var currentOptions: AnyOptions = .text(TextOptions(text: "Default text"))
    
    var body: some View {
        OptionsContainerView(anyOptions: $currentOptions)
            .frame(width: 300, height: 200)
    }
}

The idea is that you can progressively refine this. Use subviews to provide UI for features that are common to a subset of the option types, and add computed properties to AnyOptions that return either the subview or a HiddenView(), as required. For example, a widget that configures thumbnail creation makes sense for photos and video, but text options wouldn't need a subview for that.

This approach is very quick and clear for relatively simple use cases, but the bindings can get hairy very quickly. If you want a truly generic, extensible framework for dynamically configurable views, you're probably better off using protocols.