How to add cases to a public enum without breaking your API

⋅ 5 min read ⋅ Swift Swift Evolution Enum

Table of Contents

If we ship a public enum in a package, we can never add a case to it without breaking someone.

public enum PizzaFlavor {
case hawaiian
case pepperoni
case cheese
}

Swift requires switches to be exhaustive, so a client can write this.

switch pizzaFlavor {
case .hawaiian: throw BadFlavorError()
case .pepperoni: return .delicious
case .cheese: return .delicious
}

The moment we add .veggieSupreme, that switch stops compiling. Adding one case is a major version bump.

SE-0487 gives us a way out with a new @nonexhaustive attribute.

Note: unlike the rest of the Swift 6.2 features, this one landed in Swift 6.2.3.

Why resilient libraries never had this problem

Apple's own frameworks solved this years ago in SE-0192.

A library built with -enable-library-evolution — a "resilient" library — has non-exhaustive enums by default. Clients switching over one must include @unknown default.

switch pizzaFlavor {
case .hawaiian: throw BadFlavorError()
case .pepperoni: return .delicious
case .cheese: return .delicious
@unknown default:
return .delicious
}

That clause is what makes adding a case safe. The client has already said what to do with anything it doesn't recognise.

The catch is that this behaviour was only available in the resilient dialect. A normal Swift package couldn't opt in, and had no way to make a public enum extensible.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

What that cost us

The proposal lists the workarounds this forced on package authors, and they're all familiar:

  • Errors shouldn't be enums. A new error case can't be added to an existing enum, which is why we end up with a proliferation of error types — or "fake" enums built from a struct and static lets, which then don't work with the nice pattern matching in catch blocks.
  • Anything inherently open-ended is awkward. SwiftNIO represents HTTP status codes as an enum. New status codes mean either a deprecate-and-replace, or forcing them through a .custom case.
  • Any set you haven't fully enumerated is a trap, because discovering a new member later is a breaking change.

The attribute

/// Module A
@nonexhaustive
public enum PizzaFlavor {
case hawaiian
case pepperoni
case cheese
}

Now a client in another module gets an error for an exhaustive switch.

/// Module B
switch pizzaFlavor {
// error: Switch covers known cases, but 'PizzaFlavor' may have
// additional unknown values, possibly added in future versions
case .hawaiian: throw BadFlavorError()
case .pepperoni: return .delicious
case .cheese: return .delicious
}

Adding @unknown default fixes it, and from then on we can add cases freely.

Inside our own module, nothing changes

This is the part I like most.

Within the same module or package, an @nonexhaustive enum still switches exhaustively, and adding @unknown default there produces a warning.

That's the right split. Code we ship together is co-developed, so when we add a case we want the compiler to walk us through every place that needs updating. It's only across a package boundary — where the other side upgrades on its own schedule — that we want the escape hatch.

It can't be @frozen too

@frozen on an enum is the opposite promise: no cases will ever be added. Marking an enum both @frozen and @nonexhaustive is a compiler error.

Worth knowing that @frozen also remains the recommended tool for one case the proposal doesn't otherwise cover. If we split an app across several packages that are versioned together, we may want exhaustive matching across all of them. Until build tooling can express "these packages are one unit", @frozen is the way to get that.

Adding the attribute is itself a breaking change

Here's the trap. On a non-resilient library:

  • Adding @nonexhaustive is an API breaking change.
  • Removing it is not.

That reads backwards until you think about it from the client's side. Adding the attribute is what forces every existing exhaustive switch to grow an @unknown default, and until they do, their code doesn't compile.

So the fix for future breakage is itself breakage, once.

Staging it in with @nonexhaustive(warn)

Which is exactly what the second form is for.

@nonexhaustive(warn)
public enum Foo {
case foo
}

Clients now get a warning instead of an error.

switch foo {
// warning: Enum might be extended later. Add an @unknown default case.
case .foo: break
}

Their code keeps compiling, but they've been told what's coming. Later, when we actually add a case in a new major version, a client who ignored the warning gets both.

switch foo {
// error: Unhandled case bar
// warning: Enum might be extended later. Add an @unknown default case.
case .foo: break
}

@nonexhaustive(warn) doesn't remove the need for a major version bump when we add the case. What it buys is advance notice, so the break doesn't arrive as a surprise.

One asymmetry to remember: adding (warn) is safe, but removing it is breaking, since that upgrades everyone's warning back to an error.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

Summary

@nonexhaustive lets a public enum in an ordinary Swift package be extended later, the way enums in resilient libraries always could.

Clients in other modules need @unknown default. Our own module and package keep exhaustive matching, so the compiler still helps us internally. It can't be combined with @frozen.

The thing to plan around is that adopting it is a one-time breaking change. If we have existing public enums we expect to grow, @nonexhaustive(warn) lets us warn people first and break them later — and swift package diagnose-api-breaking-changes understands the attribute, so the tooling keeps up.


Read more article about Swift, Swift Evolution, Enum, or see all available topic

Enjoy the read?

If you enjoy this article, you can subscribe to the weekly newsletter.
Every Friday, you'll get a quick recap of all articles and tips posted on this site. No strings attached. Unsubscribe anytime.

Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.

If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.

Become a patron Buy me a coffee Tweet Share
Previous
How to declare a fixed-size array in Swift

Swift 6.2 adds InlineArray, a fixed-size array whose length is part of its type, along with a shorthand for writing it: [5 of Int].

Next
How to use if and switch as expressions in Swift

Swift 5.9 lets an if or switch produce a value instead of just assigning one. It removes a var we never wanted, but the single-expression rule catches people out.

← Home