Skip to content

Commit 9c93e57

Browse files
Pitch for extensible enums (#2679)
* Extensible enums * Review * Reviews and same module/package enums * Clarify API impact of `@extensible` and add alternatives considered section * More review comments * Pitch feedback * Pitch feedback: Adding migration path and expand on implications inside the same package * Fix small mistake * Address adding associated values * Remove new annotations and re-use `@frozen` * Update metadata with implementation and pitch * Small fixups * Add migration paths * Minor spelling fix ups * Update proposal to focus on `@extensible` attribute * Change implementation link * Add `@preEnumExtensibility` to the proposal * Source compat section for preEnumExtensibility * Kick off review Also rewords opening description of resilience. --------- Co-authored-by: Ben Cohen <[email protected]>
1 parent bf3eec7 commit 9c93e57

File tree

1 file changed

+319
-0
lines changed

1 file changed

+319
-0
lines changed

proposals/NNNN-extensible-enums.md

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
# Extensible enums
2+
3+
* Proposal: [SE-NNNN](NNNN-extensible-enums.md)
4+
* Authors: [Pavel Yaskevich](https://github.com/xedin), [Franz Busch](https://github.com/FranzBusch), [Cory Benfield](https://github.com/lukasa)
5+
* Review Manager: [Ben Cohen](https://github.com/airspeedswift)
6+
* Status: **In active review (May 25—Jun 5, 2025)**
7+
* Bug: [apple/swift#55110](https://github.com/swiftlang/swift/issues/55110)
8+
* Implementation: [apple/swift#80503](https://github.com/swiftlang/swift/pull/80503)
9+
* Upcoming Feature Flag: `ExtensibleAttribute`
10+
* Review: ([pitch](https://forums.swift.org/t/pitch-extensible-enums-for-non-resilient-modules/77649))
11+
12+
Previously pitched in:
13+
14+
- https://forums.swift.org/t/extensible-enumerations-for-non-resilient-libraries/35900
15+
- https://forums.swift.org/t/pitch-non-frozen-enumerations/68373
16+
17+
Revisions:
18+
- Re-focused this proposal on introducing a new `@extensible` attribute and
19+
moved the language feature to a future direction
20+
- Introduced a second annotation `@nonExtensible` to allow a migration path into
21+
both directions
22+
- Added future directions for adding additional associated values
23+
- Removed both the `@extensible` and `@nonExtensible` annotation in favour of
24+
re-using the existing `@frozen` annotation
25+
- Added the high level goals that this proposal aims to achieve
26+
- Expanded on the proposed migration path for packages with regards to their
27+
willingness to break API
28+
- Added future directions for exhaustive matching for larger compilation units
29+
- Added alternatives considered section for a hypothetical
30+
`@preEnumExtensibility`
31+
- Added a section for `swift package diagnose-api-breaking-changes`
32+
33+
## Introduction
34+
35+
This proposal provides developers the capabilities to mark public enums in
36+
non-resilient Swift libraries as extensible. This makes Swift `enum`s vastly
37+
more useful in public API of such libraries.
38+
39+
## Motivation
40+
41+
When Swift was enhanced to add support for ABI-stable libraries that were built with
42+
"library evolution" enabled ("resilient" libraries as we call them in this proposal),
43+
the Swift language had to support these libraries vending enums that might have cases
44+
added to them in a later version. Swift supports exhaustive switching over cases.
45+
When binaries are compiled against a ABI-stable library they need to be able to handle the
46+
addition of a new case by that library later on, without needing to be rebuilt.
47+
48+
Consider the following simple library to your favorite pizza place:
49+
50+
```swift
51+
public enum PizzaFlavor {
52+
case hawaiian
53+
case pepperoni
54+
case cheese
55+
}
56+
```
57+
58+
In the standard "non-resilient" mode, users of the library can write exhaustive switch
59+
statements over the enum `PizzaFlavor`:
60+
61+
```swift
62+
switch pizzaFlavor {
63+
case .hawaiian:
64+
throw BadFlavorError()
65+
case .pepperoni:
66+
try validateNoVegetariansEating()
67+
return .delicious
68+
case .cheese:
69+
return .delicious
70+
}
71+
```
72+
73+
Swift requires switches to be exhaustive i.e. the must handle every possibility.
74+
If the author of the above switch statement was missing a case (perhaps they forgot
75+
`.hawaiian` is a flavor), the compiler will error, and force the user to either add a
76+
`default:` clause, or to add the missing case.
77+
78+
If later a new case is added to the enum (maybe `.veggieSupreme`), exhaustive switches
79+
over that enum might no longer be exhaustive. This is often _desirable_ within a single
80+
codebase (even one split up into multiple modules). A case is added, and the compiler will
81+
assist in finding all the places where this new case must be handled.
82+
83+
But it presents a problem for authors of both resilient and non-resilient libraries:
84+
85+
- For non-resilient libraries, adding a case is a source-breaking API change: clients
86+
exhaustively switching over the enum will no longer compile. So can only be done with
87+
a major semantic version bump.
88+
- For resilient libraries, even that is not an option. An ABI-stable library cannot allow
89+
a situation where a binary that has not yet been recompiled can no longer rely on its
90+
switches over an enum are exhaustive.
91+
92+
Because of the implications on ABI and the requirement to be able to evolve
93+
libraries with public enumerations in their API, the resilient language dialect introduced
94+
"non-exhaustive enums" in [SE-0192](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0192-non-exhaustive-enums.md).
95+
96+
If the library was compiled with `-enable-library-evolution`, when a user attempts to
97+
exhaustively switch over the `PizzaFlavor` enum the compiler will emit an error
98+
(when in Swift 6 language mode, a warning in prior language modes), requiring users
99+
to add an `@unknown default:` clause:
100+
101+
```swift
102+
switch pizzaFlavor {
103+
case .hawaiian:
104+
throw BadFlavorError()
105+
case .pepperoni:
106+
try validateNoVegetariansEating()
107+
return .delicious
108+
case .cheese:
109+
return .delicious
110+
@unknown default:
111+
try validateNoVegetariansEating()
112+
return .delicious
113+
}
114+
```
115+
116+
The user is forced to specify how cases are handled if they are introduced later. This
117+
allows ABI-stable libraries to add cases without risking undefined behavior in client
118+
binaries that haven't yet been recompiled.
119+
120+
When a resilient library knows that an enumeration will never be extended, the author
121+
can annotate the enum with `@frozen`, which in the case of enums is a guarantee that no
122+
further cases can be added. For example, the `Optional` type in the standard library is
123+
frozen, as no third option beyond `some` and `none` will ever be added. This brings
124+
performance benefits, and also the convenience of not requiring an `@unknown default` case.
125+
126+
`@frozen` is a powerful attribute that can be applied to both structs and enums. It has a
127+
wide ranging number of effects, including exposing their size directly as part of the ABI
128+
and providing direct access to stored properties. However, on enums it happens to
129+
have source-level effects on the behavior of switch statements by clients of a library.
130+
This difference was introduced late in the process of reviewing SE-0192.
131+
132+
Extensibility of enums is also desirable for non-resilient libraries. Without it, there is no
133+
way for a Swift package to be able to evolve a public enumeration without breaking the API.
134+
However, in Swift today it is not possible for the default, "non-resilient" dialect to opt-in
135+
to the extensible enumeration behavior. This is a substantial limitation, and greatly reduces
136+
the utility of enumerations in non-resilient Swift.
137+
138+
Over the past years, many packages have run into this limitation when trying to express APIs
139+
using enums. As a non-exhaustive list of problems this can cause:
140+
141+
- Using enumerations to represent `Error`s is inadvisable, as if new errors need
142+
to be introduced they cannot be added to existing enumerations. This leads to
143+
a proliferation of `Error` enumerations. "Fake" enumerations can be made using
144+
`struct`s and `static let`s, but these do not work with the nice `Error`
145+
pattern-match logic in catch blocks, requiring type casts.
146+
- Using an enumeration to refer to a group of possible ideas without entirely
147+
exhaustively evaluating the set is potentially dangerous, requiring a
148+
deprecate-and-replace if any new elements appear.
149+
- Using an enumeration to represent any concept that is inherently extensible is
150+
tricky. For example, `SwiftNIO` uses an enumeration to represent HTTP status
151+
codes. If new status codes are added, SwiftNIO needs to either mint new
152+
enumerations and do a deprecate-and-replace, or it needs to force these new
153+
status codes through the .custom enum case.
154+
155+
This proposal plans to address these limitations on enumerations in
156+
non-resilient Swift.
157+
158+
## Proposed solution
159+
160+
We propose to introduce a new `@extensible` attribute that can be applied to
161+
enumerations to mark them as extensible. Such enums will behave the same way as
162+
non-frozen enums from resilient Swift libraries.
163+
164+
An example of using the new attribute is below:
165+
166+
```swift
167+
/// Module A
168+
@extensible
169+
public enum PizzaFlavor {
170+
case hawaiian
171+
case pepperoni
172+
case cheese
173+
}
174+
175+
/// Module B
176+
switch pizzaFlavor { // error: Switch covers known cases, but 'MyEnum' may have additional unknown values, possibly added in future versions
177+
case .hawaiian:
178+
throw BadFlavorError()
179+
case .pepperoni:
180+
try validateNoVegetariansEating()
181+
return .delicious
182+
case .cheese:
183+
return .delicious
184+
}
185+
```
186+
187+
### Exhaustive switching inside same module/package
188+
189+
Code inside the same module or package can be thought of as one co-developed
190+
unit of code. Inside the same module or package, switching exhaustively over an
191+
`@extensible` enum inside will not require an`@unknown default`, and using
192+
one will generate a warning.
193+
194+
### `@extensible` and `@frozen`
195+
196+
An enum cannot be `@frozen` and `@extensible` at the same time. Thus, marking an
197+
enum both `@extensible` and `@frozen` is not allowed and will result in a
198+
compiler error.
199+
200+
### API breaking checker
201+
202+
The behavior of `swift package diagnose-api-breaking-changes` is also updated
203+
to understand the new `@extensible` attribute.
204+
205+
### Staging in using `@preEnumExtensibility`
206+
207+
We also propose adding a new `@preEnumExtensibility` attribute that can be used
208+
to mark enumerations as pre-existing to the `@extensible` attribute. This allows
209+
developers to mark existing public enumerations as `@preEnumExtensibility` in
210+
addition to `@extensible`. This is useful for developers that want to stage in
211+
changing an existing non-extensible enum to be extensible over multiple
212+
releases. Below is an example of how this can be used:
213+
214+
```swift
215+
// Package A
216+
public enum Foo {
217+
case foo
218+
}
219+
220+
// Package B
221+
switch foo {
222+
case .foo: break
223+
}
224+
225+
// Package A wants to make the existing enum extensible
226+
@preEnumExtensibility @extensible
227+
public enum Foo {
228+
case foo
229+
}
230+
231+
// Package B now emits a warning downgraded from an error
232+
switch foo { // warning: Enum might be extended later. Add an @unknown default case.
233+
case .foo: break
234+
}
235+
236+
// Later Package A decides to extend the enum and releases a new major version
237+
@preEnumExtensibility @extensible
238+
public enum Foo {
239+
case foo
240+
case bar
241+
}
242+
243+
// Package B didn't add the @unknown default case yet. So now we we emit a warning and an error
244+
switch foo { // error: Unhandled case bar & warning: Enum might be extended later. Add an @unknown default case.
245+
case .foo: break
246+
}
247+
```
248+
249+
While the `@preEnumExtensibility` attribute doesn't solve the need of requiring
250+
a new major when a new case is added it allows developers to stage in changing
251+
an existing non-extensible enum to become extensible in a future release by
252+
surfacing a warning about this upcoming break early.
253+
254+
## Source compatibility
255+
256+
### Resilient modules
257+
258+
- Adding or removing the `@extensible` attribute has no-effect since it is the default in this language dialect.
259+
- Adding the `@preEnumExtensibility` attribute has no-effect since it only downgrades the error to a warning.
260+
- Removing the `@preEnumExtensibility` attribute is an API breaking since it upgrades the warning to an error again.
261+
262+
### Non-resilient modules
263+
264+
- Adding the `@extensible` attribute is an API breaking change.
265+
- Removing the `@extensible` attribute is an API stable change.
266+
- Adding the `@preEnumExtensibility` attribute has no-effect since it only downgrades the error to a warning.
267+
- Removing the `@preEnumExtensibility` attribute is an API breaking since it upgrades the warning to an error again.
268+
269+
## ABI compatibility
270+
271+
The new attribute does not affect the ABI of an enum since it is already the
272+
default in resilient modules.
273+
274+
## Future directions
275+
276+
### Aligning the language dialects
277+
278+
In a previous iteration of this proposal, we proposed to add a new language
279+
feature to align the language dialects in a future language mode. The main
280+
motivation behind this is that the current default of non-extensible enums is a
281+
common pitfall and results in tremendous amounts of unnoticed API breaks in the
282+
Swift package ecosystem. We still believe that a future proposal should try
283+
aligning the language dialects. This proposal is focused on providing a first
284+
step to allow extensible enums in non-resilient modules.
285+
286+
Regardless of whether a future language mode changes the default for non-resilient
287+
libraries, a way of staging in this change will be required (similar to how the
288+
`@preconcurency` attribute facilitated incremental adoption of Swift concurrency).
289+
290+
### `@unknown catch`
291+
292+
Enums can be used for errors. Catching and pattern matching enums could add
293+
support for an `@unknown catch` to make pattern matching of typed throws align
294+
with `switch` pattern matching.
295+
296+
### Allow adding additional associated values
297+
298+
Adding additional associated values to an enum can also be seen as extending it
299+
and we agree that this is interesting to explore in the future. However, this
300+
proposal focuses on solving the primary problem of the usability of public
301+
enumerations in non-resilient modules.
302+
303+
### Larger compilation units than packages
304+
305+
During the pitch it was brought up that a common pattern for application
306+
developers is to split an application into multiple smaller packages. Those
307+
packages are versioned together and want to have the same exhaustive matching
308+
behavior as code within a single package. As a future direction, build and
309+
package tooling could allow to define larger compilation units to express this.
310+
Until then developers are encouraged to use `@frozen` attributes on their
311+
enumerations to achieve the same effect.
312+
313+
## Alternatives considered
314+
315+
### Different names for the attribute
316+
317+
We considered different names for the attribute such as `@nonFrozen`; however,
318+
we felt that `@extensible` communicates the idea of an extensible enum more
319+
clearly.

0 commit comments

Comments
 (0)