Avoiding `enum` across `public` API boundaries?

What happens if I need an API that returns a Planet from our solar system? Suppose I am a library maintainer and this API is public for consumers of my library. What data structure do I use to represent that Planet? Our first choice might be something like an enum. This is an example from TSPL:

public enum Planet {
  case mercury, venus, earth, mars, jupiter, saturn, uranus, neptune
}

This works… but what happens when we add a new Planet?

public enum Planet {
  ...
  case pluto
}

This is now a breaking change. Evolution proposals like SE-0192 and SE-0260 have shipped solutions to help protect against this from happening. A different approach is suggested here by @curt:

Here is what this idea would look like on our Planet:

public struct Planet {
  private var kind: Kind
  
  public static var mercury: Planet { Planet(kind: .mercury) }
  public static var venus: Planet { Planet(kind: .venus) }
  public static var earth: Planet { Planet(kind: .earth) }
  public static var mars: Planet { Planet(kind: .mars) }
  public static var jupiter: Planet { Planet(kind: .jupiter) }
  public static var saturn: Planet { Planet(kind: .saturn) }
  public static var uranus: Planet { Planet(kind: .uranus) }
  public static var neptune: Planet { Planet(kind: .neptune) }
  
  private enum Kind {
    case mercury, venus, earth, mars, jupiter, saturn, uranus, neptune
  }
}

This pattern works and is already being used across the ecosystem. But do we really need all this boilerplate? Could we image a world where we could do this:

@algebraic public struct Planet {
  case mercury, venus, earth, mars, jupiter, saturn, uranus, neptune
}

Where a potential algebraic attribute synthesizes that extra boilerplate for us so that our struct functions more similar to an Algebraic data type? While also offering an alternative solution to prevent breaking changes from adding new case values?

I could probably hack something together with macros that can sort of make this work today… but I was also throwing this out there to see if anyone else had thoughts about whether or not this belongs in the language itself.

If we do not choose to ship something like this as part of the language itself… would anyone be interested in helping to update TSPL to explain more about the long-term effects of relying on enum values across API boundaries? I do understand we like to achieve an ideal of progressive disclosure without front-loading too much information… but I also think this topic is important enough that future library maintainers might need to learn more about this sooner rather than later.

2 Likes

That’s the entire purpose of swift-evolution/proposals/0487-extensible-enums.md at main · swiftlang/swift-evolution · GitHub

@nonexhaustive
public enum Planet {
  case mercury, venus, earth, mars, jupiter, saturn, uranus, neptune
}

forces clients to handle the @unknown default case for when you add a new case.

3 Likes

I believe this is solved by SE-0487 and @nonexhaustive.

2 Likes

I could see this as being one good option available to library maintainers… but this follow-up note from @curt also leads me to believe library maintainers might not always want clients to choose @unknown default:

But also that was specifically mentioning undefined default. I don't know for sure if that was a reference to @unknown or not.

@unknown default is essentially the same as Curt's suggestion if you try to switch over the value, at least if the type conforms to Equatable, as you get a free ~=.

1 Like

Unless you're talking about the ability to suppress switching altogether? That's interesting, but I'm not sure it's possible for an enum, as there seems to be no way to suppress ~=.

1 Like

I'd prefer my app to stop compiling when Pluto is added because I may want to do something about it. That's why I feel uneasy whenever I have to put default / @unknown default in a switch and whenever possible going without it I go without it.

6 Likes

This I think gets back to my question: orthogonal to the improvements that have been made to enum over the years to what extent is the struct pattern above a legit alternative that library maintainers choose to model an ADT?

I use it when it needs to be publicly extensible, like an HTTPMethod type, as users can define their own methods. It's also useful for simple types where I'm worried they may need to expand, or I don't want to offer switching, but that's fairly rare. However, enum's associated values are hard to beat, and would be labor intensive to replicate in a struct, so for things like errors that capture data, or exclusive states, enums are really the only way to go. I save the enum case expansion for major versions, usually.

1 Like

SwiftUI has a constraint you don't: ABI compatibility.

If you add a case to an enum, you can bump your major version, and expect your consumers to add it to their switch statements. In a lot of cases, that's by far the best outcome. SwiftUI doesn't have that luxury.

SwiftUI has to choose between @frozen enums that can never, ever change, non-frozen enums that require @unknown default to switch over (and the associated value representation can never, ever change), or the approach they seem to have settled on with struct and static func constructors. All of which offer different tradeoffs, but I can quite understand how they came to the solution they did.

6 Likes

The struct-with-hidden-enum-inside approach can be a good option if the type is only used as input to your APIs. In that case it is ergonomically identical while freeing you up for future evolution in a number of ways. For example, you can add new overloads for existing “cases” with different sets of parameters, or convert some cases into using the same underlying case for storage. But if you expect users of your API to receive a value of the type as an output from your API, the ability to exhaustively switch on the enum is quite valuable (even if they do have to add an @unknown default for source compatibility reasons).

As Keith mentions, Swift packages don’t have to deal with ABI compatibility. That means there are a lot more “dirty tricks” you could pull to ensure at least partial source compatibility in the face of changes to your enum. (Things like static methods to construct cases)

2 Likes

I think it’s a legit pattern to use if the purpose of the library creator is to provide a type with a limited number of known constructors, but that must not be switched over by clients. So, basically, an enum with suppressed switching.

But I would make it even simpler, as the internal structure is irrelevant:

public struct Planet: Sendable {
  private var name: String
  
  public static let mercury: Planet = .init(name: "mercury")
  // other cases
}

About the idea of suppressing switchability, this unfortunately doesn’t seem to work:

@available(*, deprecated, message: "DO NOT SWITCH OVER")
func ~= (pattern: Planet, value: Planet) -> Bool {
  fatalError()
}

Switching over a Planet value doesn’t produce warnings if Planet is an enum, so it’s not really overloading the default behavior, but it does work if Planet is an Equatable struct.

2 Likes

I could switch over SwiftUI types that are known to use the struct instead of enum trick:

func test(_ colorSpace: Gradient.ColorSpace) {
    switch colorSpace {
        case .device: break
        case .perceptual: break
        default: break // without this will get an error: "Switch must be exhaustive"
    }
}

why do you think that's undesired?

1 Like

I don’t, I’m just saying that my interpretation of the linked messages from @curt, and by extension of the OP, is to have a way to suppress switching over the values for a certain type, because it’s perceived as a source of bugs for library clients. I corrected the wording of my post to be more explicit (replaced “cannot” with “must not”).

1 Like

Re the linked message: I do not understand it either :-)

Though, even when I can't switch over a thing (for some reason) I'm still able to effectively do the same via if-else (unless the thing is not equatable):

    if colorSpace == .device { ...
    } else if colorSpace == .perceptual { ...
    } else { ...
    }

and expose myself to the same "badness" (whatever it is) that switch has, so I still can't see how prohibiting switching protects from anything. OTOH making the thing non equatable probably does.

1 Like

Right, I'm struggling to think of a situation where you'd want an enum to be unswitchable. For such a type to exist, it would need to also be non-equatable (since switching can always be "simulated" by checking for equality with all known values). But "unswitchability" isn't the right way to phrase what's being discussed here—what you really mean is to "prevent pattern matching", and once you strip that core capability away from an enum, you really do just have a type that provides a set of factory functions (the cases) and some other members to access value-dependent information, and as pointed out above in Avoiding `enum` across `public` API boundaries? - #12 by ExFalsoQuodlibet, that's already possible today with structs.

2 Likes

I'm not sure I understand the takeaway. Say my enum-like type has to be equatable, will it still make sense to make it a struct or (as per above) it does not make any sense? Or if the type is not currently equatable but could become in the future? Or the type will never be Equatable? Let's assume this is for the use cases when those considerations about ABI stability matter.

FWIW, in SwiftUI I could see (all public):

1 Like

Same here, usually my unknown default is an assertion failure and a default value that tries to minimize impact on production, but it does mean we usually find out about the new case via user feedback unless we religiously stay up-to-date..

2 Likes

An extension to that idea – make it crash for some (percent of) users via using preconditions instead of asserts - you'll then get crash reports from that cohort of users.

2 Likes

Wouldn’t the consumer of the library update to the version that adds Pluto and then get a compilation error (rather than a crash) on any switch statements and have to add the case for it?

1 Like