I'm exited to bring a previous proposal for extensible enums for non-resilient modules from @lukasa back together with @xedin. Over the past years, enumerations in non-resilient modules have repeatedly caused problems for API evolution leading to the general guidance of avoiding enums for public APIs in Swift packages.
The goal of the proposal is to align the behavior of enumerations in Swift between the resilient and non-resilient language dialects.
I'm not so keen on this, to be honest. I work in a project that's split into many modules. Any time I use an API from a resilient module with a non-frozen enum, I find it rather annoying. I'd rather just get an error when I try to recompile with the newest version. I know that is logically impossible without just crashing in the newest version, and I don't want that either, so I understand why it exists, but our project has so many enums and switch statements where we've exhaustively switched over enum cases where we'd then have to add @unknown default if we wanted to adopt the new language mode. It could remain opt in forever, but I think that introduces a weird split in the language. I think I'd prefer the state it's in now; require it for the combo of resilient modules/non-frozen enums, leave it out everywhere else).
Will @unknown default be required if you use a enum from an imported resilient module, and both your module and the imported module were built with the same SWIFT_PACKAGE_NAME?
@JuneBash (and I think this may answer your question as well @j-f1) If you're referring to modules within the same package, the @unknown default case won't be necessary. From the proposal (emphasis mine):
Modules consuming other modules with the language feature enabled will be forced to add an @unknown default: case to any switch state for enumerations that are not marked with @frozen. Importantly, this only applies to enums that are imported from other modules that are not in the same package. For enums inside the same modules of the declaring package switches is still required to be exhaustive and doesn't require an @unknown default: case.
That helps a bit, but we have a package separate from our main app target. There are also 3rd party frameworks I use that vendor enums I exhaustively switch over.
As a library maintainer, not being able to add enums to public API which I know might change in the future is very painful. We have grown to reflexively question usage of enums in our public APIs because they cannot be changed without a new major version.
I did not read past the headline, and I want to say a thousand times yes! Enums are essentially useless in any open source package without being able to mark them open in some form, which is a shame as they are one of Swift's most powerful features.
I used to contribute to and still use an OpenAPI SDK generator called CreateAPI. The schema that I personally generate from are quite large and many additions are made across many API surfaces, so when new cases are added to enums I may not have the availability to update all of the new cases where exhaustivity was previously used. Or there are some contexts where, as a client, the overall work for properly supporting the new case is out of the immediate scope.
Knowing that the server can change faster than a free-time-contributor-based client, the workaround has always been to have an optional initializer for unknown cases, which isn't the best.
I think this is wholly beneficial to packages since they can't benefit from library evolution mode.
Although, I'm wary how enabling the language feature is properly communicated. I've personally been on a "reduce implicitness for clarity reasons" run lately, so I do enjoy the @extensible attribute.
Thanks for working on this. I think that the future direction of turning on ExtensibleEnums by default in a future language mode is important and I hope we consider actually making that the plan of record, rather than leaving that detail to a future proposal.
The difference in behavior for clients when the same library module is built with and without library evolution has been a constant source of annoyance in the compiler codebase. Developers will often iterate on Swift libraries that are part of the compiler by building those libraries in isolation as packages, without library evolution. They don't realize then that switches in downstream code that use new enums will produce warnings when that same library is rebuilt with library evolution enabled for the real compiler build on ABI stable platforms. I'm really looking forward to being able to make the diagnostic behavior consistent and avoid this.
Can you provide a moral equivalent of a "diff," so to speak, to highlight what has changed from prior pitches on the subject? Was there an obvious defect in prior iterations of this idea that has been amended? Was there engineering work that blocked prior implementation which has been unblocked?
I think you're going to get overwhelming support for not resolving the language dialect issue, but the hard part is in the details of how it's going to be implemented and staged in. It would be helpful, therefore, to understand what ended up being the barrier to taking this up over the past many years we've known about and wanted to solve the problem, and what has changed now to make this pitch viable.
As a package maintainer: unquestionable +9999999999999999999999999.
Optimally with some help from compiler magics, these extensible enums would also be able to handle unknown cases when decoding through Codable.
Gets complicated but that would be ideal.
Having finally read the pitch, I'm in favor of the proposed solution.
Assuming it would be enabled by default in a future language mode though, I wonder if we would want a counterpart language feature flag to disable it by default, so that packages that previously declared a package manifest version of 6.0 could increment it to 6.x (where x is the hypothetical future version), and disable the default explicitly to maintain source compatibility. This would allow new packages made on 6.x or later to be able to leave out the ExtensibleEnums flag.
Finally, @unknown default: would work well for switch cases, but now that we have typed errors, the final catch case will now be mandatory if enums are used as errors. We haven't really needed an @unknown catch yet up until now because most people haven't used enums in this context, but it may be worth accounting for at this time.
I don't think I'd be in favor of this for decoding at least — if the goal is for a client to refer to a package that has shared types with a server, and is delivered a potentially new case (one the client was not compiled to include) it basically means that decoding would never fail? Worse, if the value were successfully "decoded" into a type, how would a custom description handle it? It's not like the module itself would be expecting this new case if it were compiled with an older version. If the client were compiled with a newer version of the library, it would be handled just fine, so I'm not sure we need additional functionality here.
I feel that extensible enums should be opt-in with @extensible, and not-opt out (assuming eventually there is a Swift language version that has ExtensibleEnums always turned on).
This pitch is focused on open source modules, but in apps that are broken into modules, frozen enums are both a core safety feature and way to prevent bugs when new cases are introduced.
I think library authors don't discuss the perils of @unknown default enough, and I don't think it's a pattern that should be encouraged in non-resilient modules.
@unknown default puts enum consumers in a really difficult situation: there often is no way to write code that handles any of infinite possible new cases. It ends up as something like a log statement without actually invoking product code, forcing other parts of the codebase to now be optional where it shouldn't. It really shouldn't be a solution to anything but backwards compatibility for closed-source iOS releases.
Importantly, this only applies to enums that are imported from other modules that are not in the same package. For enums inside the same modules of the declaring package switches are still required to be exhaustive and don't require an @unknown default: case.
So the behavior should stay the same for you.
Most of the times it's not an option for a package trying to follow Semantic Versioning 2.0 which is the standard nowadays.
If your enum is an usual enum which has a chance to need to have a new case in the future, you usually have to use "Fake" enums like discussed in the proposal.
My experience from the first party and third party open source packages is similar to Franz's: 99% of the time you want extensible enums (and yes, i think 99% is an accurate estimate. If anything it might be an underestimate).
What this proposal proposes is also the safer way. If you happen to forget to mark your enum as frozen, you can just do it in a semver-patch release.
But if you switch the default behavior, you cannot mark an enum @extensible without a semver-major release.
If you really want to have errors instead of warnings, I think you can use the other new feature that I saw was being discussed which enables promoting specific warnings to errors (and vise-versa) that you can use in CI.
For clarity, I'd use the term non-frozen rather than extensible. This way it's immediately clear how the two terms are related and that you're just flipping the default between the two with the feature flag.
NonFrozenEnums feature flag.
@nonfrozen attribute.
"Non-Frozen Enums for non-resilient modules" proposal name.
If it's open source, then why is it a problem for consumers to update their switch cases when a new case is added? These packages are typically shipped bundled with the app.
Why is a semver-major release something to be avoided?