How do NS_ENUM, NS_OPTIONS and NS_CLOSED_ENUM import, and why does Swift demand @unknown default?

4.9.1 · L3 · Interview

Predict first

currentMode() is an Objective-C function that returns (Mode)7, but the enum has three cases. What does this print?

// Objective-C: typedef NS_ENUM(NSInteger, Mode) { ModeOff, ModeOn, ModeAuto };
//               Mode currentMode(void) { return (Mode)7; }
func describe(_ m: Mode) -> String {
    switch m {
    case .off: return "off"
    case .on: return "on"
    case .auto: return "auto"
    @unknown default: return "unknown(\(m.rawValue))"
    }
}
print(describe(.on), describe(currentMode()))
  • on auto
  • on unknown(7)
  • a crash

Predict first

Does this compile in Swift 6 mode if Mode is an NS_ENUM?

// Objective-C: typedef NS_ENUM(NSInteger, Mode) { ModeOff, ModeOn, ModeAuto };
func a(_ m: Mode) -> String {
    switch m { case .off: "off"; case .on: "on"; case .auto: "auto" }
}
  • Yes
  • No: the switch needs @unknown default

Predict first

And with NS_CLOSED_ENUM(NSInteger, Side) (cases SideLeft, SideRight)?

// Objective-C: typedef NS_CLOSED_ENUM(NSInteger, Side) { SideLeft, SideRight };
func b(_ s: Side) -> String {
    switch s { case .left: "l"; case .right: "r" }
}
print(b(.left))
  • It compiles
  • It needs @unknown default

Mental model. An imported enum is a menu from a restaurant that may add dishes after you printed your order sheet. @unknown default is the line for “anything else”. A closed enum is a menu carved in stone.

NS_ENUM(NSInteger, Mode) imports as a Swift enum with the common prefix removed (ModeOn becomes .on) and a rawValue of the declared integer type. NS_OPTIONS imports as an OptionSet struct, so you write [.read, .write] and test with contains. A plain C enum without these macros imports as a struct of constants, which is why the macros matter. Both enums and option sets keep their C representation: an NS_ENUM can hold any integer, including a value that has no case.

That is why Swift treats an imported enum as non-frozen and insists on @unknown default in a switch: a newer framework (or buggy code) may deliver a value your code has never heard of. It is a warning in Swift 5 mode and an error in Swift 6 mode. If the declaration will genuinely never grow, NS_CLOSED_ENUM tells Swift it is frozen and the exhaustive switch needs no default. Use it only for sets like left and right; a wrong promise would make Swift take impossible branches. For an error domain, NS_ERROR_ENUM gives the codes a Swift enum that conforms to Error.

The value 7 is stored in an integer and no case matches it, so only the @unknown default branch can handle it. There is no crash and no trap.

The enum is non-frozen, so the compiler reports that it may have additional unknown values. In Swift 5 mode it is only a warning.

A closed enum is frozen, so the two cases are exhaustive and Swift needs no default.

Common traps

  • Casting a raw integer with Mode(rawValue: 7) and trusting a non-optional. For a C enum the initializer is non-failable on some imports; the value simply exists. Handle it in the switch.
  • Marking a growing enum NS_CLOSED_ENUM. Swift will treat a new case as impossible.

They might follow up with

  • Why does an NS_OPTIONS value keep unknown bits? It is still an unsigned integer under the struct.
  • How do Swift enums reach Objective-C? Only @objc enum Name: Int with no payloads does; it appears as NS_ENUM.

Related questions

  • 4.4.2 Which Swift features cannot be seen from Objective-C?
  • 4.5.1 How do nullability annotations change what Swift sees, and what happens when they lie?

How do Objective-C initializers and factory methods import, and what… →