Access control for enum cases

Introduction

Today, Swift doesn't allow access control modifiers on enum cases, unlike properties, methods and other kinds of declarations. This proposal fixes that hole in the language, making it more uniform.

Motivation

Swift doesn't allow access control modifiers on enum cases. This means that when one wants to selectively expose enum cases, or forbid clients from constructing certain enum cases, one needs to resort to exposing the API as a struct, which may be internally implemented as a wrapper around an enum. One then defines static properties/methods on the struct to mimic the ability to construct and access the "public cases" of the enum.

Such an API poses:

  • Poorer usability as one cannot pattern match while binding associated values (pattern matching still works for cases without associated values).
  • Poorer discoverability for clients which need to understand that the type actually represents some mutually exclusive cases.
  • An unnecessary maintenance burden on maintainers.

Proposed Solution

We allow access control on enum cases. (Diagnostic text is a placeholder for a proper diagnostic.)

// module M
public enum A {
  case x
  internal case y
}

// module N
import M

func f(_ a: A) {
  switch a {
  case .x: print("x")
  case .y: print("y") // error: 'y' is inaccessible due to 'internal' protection level
  }
}
let _ = A.y // error: 'y' is inaccessible due to 'internal' protection level

Based on the familiar rules of access control, the case y is only accessible within the module M which defines A, an inaccessible from any other modules. This means two things:

  1. When a downstream module tried to switch on a value of type A, it needs to use a default: clause, as y is not accessible for pattern matching.
  2. A downstream module cannot construct y.

Analogous to private(set) and similar, we also allow private(init) to allow clients to match on a case but forbid them from directly initializing it.

// Module P
public enum B {
  case v
  internal(init) case w
}

// Module Q
import P

func f(_ b: B) {
  switch b {
  case .v: print("B.v")
  case .w: print("B.w") // OK
  }
}

let _ = B.w // error: 'y' is inaccessible due to 'internal(init)' protection level

Detailed Design

(This section will be more fleshed out in the full proposal.)

Library Evolution

The semantics of non-public enum cases for frozen enums are similar to those for non-public stored properties:

  1. They are allowed: Changing a non-public case to a public one would be a binary-compatible and source-compatible change.
  2. They are exposed in the swiftinterface for layout purposes: This requires that types for any associated values must be @usableFromInline or public.

Conformance synthesis

Codable synthesis is supported for enums with non-public cases. For precedent, Codable synthesis is supported today for structs with private stored properties.

CaseIterable and RawRepresentable synthesis is not supported for enums with non-public cases, since allCases and init?(rawValue:) would allow one to easily create non-public cases.

Why the difference between Codable and CaseIterable/RawRepresentable?

  • Codable is quite central and used in many APIs, so not having Codable synthesis would be a more onerous burden. Writing a conformance to Codable is generally more work than writing a conformance for CaseIterable or RawRepresentable.
  • It is more cumbersome (but not terribly difficult) to create a non-public case using Decodable's init(from:) compared to using allCases or init?(rawValue:).

Defaulting

@unknown default will emit a warning only if all public cases not matched. (Today, all cases of a public enum are public, so the additional "public" in "public cases" is unnecessary.)

Alternatives considered

Only support access control for initialization

Matthew Johnson previously pitched private(init) without adding full access control for enum cases. Implementing the additional support for controlling pattern-matching is not too complicated, so we think it's better to have a full-fledged feature rather than only private(init).

Today, it is surprising that access control is not supported for enum cases; changing that to "you can only restrict the access control of initialization" seems like a somewhat arbitrary restriction. Rather, it is nicer to lift the restriction entirely.

Allow conformance synthesis for CaseIterable and RawRepresentable

We could do this. In case a library author carelessly slaps : CaseIterable (or : RawRepresentable) on a public enum with private cases, disabling synthesis means that the author is forced to think a bit more carefully about what they are actually trying to achieve. I think that's potentially useful. However, there is a reasonable argument to be made that this imposes an unnecessary burden on the library author, especially since the language doesn't have a spelling for @I_know_what_I'm_doing_so_please_synthesize_this.

Prototype

An initial prototype implementation is available here; you can see some code "in action" in a test case. At the time of writing, all the functionality isn't implemented (in particular, private(init) and restrictions on conformances are missing), but the core functionality of access control with private case (and similar) does work, including for initialization and pattern matching. I'm looking for feedback before proceeding. :smile:

26 Likes

cc @anandabits, you might be interested in the discussion. :slightly_smiling_face: (I don't recall if you get notified when I link your post.)

What is an actual motivating use-case for this?

2 Likes

I like this! Access control for enum cases is one of those things that I've found myself reaching for several times before remembering that we don't support it. I realize that you haven't written out the full Detailed Design section yet, but could you talk a little bit about how exhaustive matching would work under this proposal across access control boundaries? Presumably, this would be an error:

// Module A
public enum InternalCases {
  case a
  internal case b
}

// Module B
func useInternalCases(_ i: InternalCases) {
  switch i { // error (?)
    case .a: break
  }
}

I wonder if we should require some kind of modifier on the enum itself (internal(cases)?) to indicate that some cases may not be visible at the point of use. This could also let a user add non-visible cases in the future. E.g., under the most straightforward version of this proposal, the addition of internal case b above would have been a source breaking change, but if the enum had originally been written as:

public internal(cases) enum InternalCases {
  case a
}

then all clients would be forced to defensively match against non-visible cases. This would also solve the issue of how to communicate to the programmer in e.g. a swiftinterface file that the cases visible there may not be exhaustive, even before they try to match against a value of the enum.

Is there a semantic distinction you're making here between "all cases of an enum are public" and "all enum cases are as visible as the enum declaration itself"?

ETA:

The situation where I end up grasping for this feature is when I'm using an enum as some sort of Configuration signifier for another type. There might be certain "configurations" that I want to keep internal/private to the type being configured (because they're experimental, not able to be used correctly by clients, etc.), even while exposing the stable configurations as part of the same type.

1 Like

Sorry, this is a little bit hidden in the Proposed Solution section:

  1. When a downstream module tried to switch on a value of type A , it needs to use a default: clause, as y is not accessible for pattern matching.

So it matches what you are saying. Trying to match in module B in your example will fail, you need a default: clause.

Put another way, an enum case consists of two components: a pattern and an initializer/constructor. With private, both the pattern and initializer are private. With private(init), only the init is private. Even if a pattern is private, the exhaustivity relation (Space(enum InternalCases) is a union of Space(case a) and Space(case b)) is unchanged, and the requirement for switch to be exhaustive is unchanged, which means that a default case is needed. [Well, technically, this is not 100% true. default would not be needed if b were defined like internal case b(Never), since for exhaustivity checking purposes, cases with an associated value of type Never are treated as unreachable.]


[Maybe I'm misunderstanding what you are saying but:] This isn't an issue. Today, swiftinterfaces are only supported in the presence of library evolution and enums are non-frozen by default under library evolution, which means that adding new cases is allowed. Even if there are no non-public cases shown in a swiftinterface, a client cannot exhaustively match on a non-frozen enum.

This is related to @lukasa's pitch Extensible Enumerations for Non-Resilient Libraries. While this pitch doesn't support an explicit spelling for "this type doesn't have any internal cases right now, but it may add them in the future, so you can't match on it exhaustively" for non-resilient libraries, you could hypothetically mimic this using an extra case, say internal case reserved (which is a little bit tedious, but Rust libraries did use this pattern until Rust added first-class support for marking enums using #[non_exhaustive]).

From that thread, it's not entirely clear to me what the "right" solution is for this -- Joe suggests potentially changing the default semantics of enums in Swift 6 and Jordan also brings up some concerns. Since it's not clear what an ideal solution to that would look like, I don't introduce any syntax for that in this pitch.


What I was trying was that, today, some people might have a mental model of @unknown default: as:

@unknown default will emit a warning only if all cases not matched.

That mental model would be correct today, since all cases of a public enum are public for existing Swift code. However, after this change, that mental model needs to be updated to:

@unknown default will emit a warning only if all public cases not matched.

2 Likes

Thank you, I skimmed over that!

Ooh, I actually think that this example should disable exhaustive matching outside the module. I like the solution below of using internal case reserved as a stop-gap until we get a top-level "non exhaustive" marker, but I think internal case reserved(Never) is actually an even more reasonable spelling for this, since it would prevent even same-module clients from misusing the 'dummy' case value.

IOW, same-module clients can exhaustively match over just case a since they can see case b is non-constructible, but across visibility boundaries InternalCases would still publish "I might have cases that you can't see."

Ah interesting—that makes sense, though I've had the experience when working in a multi-module Swift project (w/o library evolution) that Xcode's "Jump to Definition" sometimes links me to an "interface" version of the relevant file, which hides implementations and non-accessible definitions. It seems somewhat unpredictable, so perhaps this is just a tooling bug and irrelevant to this discussion. :slightly_smiling_face:

This approach feels like it complicates things for the API client as the client needs to add a default: clause to handle cases which are private and that can't even be seen in a generated interface.

It also feels different to me than a private property in a struct or class. In those cases, typically some internal implementation detail of the struct or class is prevented from being exposed publicly, to preserve the integrity of the instance itself.

With an enumeration, additional cases don't really affect the integrity of the enumeration itself.

Maybe a limited form of enum inheritance would be a less complex solution for both the API client and vendor.

The main limitation I envision is that a 'sub-enumeration' is only allowed if it has a more restrictive access level than the base enumeration type. This would prevent general use of enumeration inheritance and also would prevent long inheritance chains. I would expect in practice only two levels of inheritance would tend to be useful.

The sub-enumeration contains all of the cases of the base type and the sub type.

// Public enumeration
public enum A {
    case x
}

// Internal enumeration subtype
internal enum A : B {
    case y
}

// error: enum inheritance must use a more restrictive access level 
public enum A : C {
    case z
}

Just as with protocol or class inheritance, everywhere the base type can be used, the sub type can be used as well.

Public APIs vended by the module would use the base type, and the compiler would enforce that as with any other public API.

Internal APIs would be written to use the sub type, allowing both the public and non-public cases to be used.

Things such as automatic Coding and CaseIterable would work as before on the base type. API clients would use the base type without any change from current behavior.

For the sub type, I would imagine Coding and CaseIterable could also be made to work automatically, since it is clear which cases are available.

API vendors would likely need their public API that accepts the public enum type to call into a method that accepts the internal sub type.

Since the API vendor would be working primarily with the subtype, its switch statements naturally need to be exhaustive for all of its cases.

Finally, methods of the super type would need to be overridden to handle the additional cases.

I am guessing the implementation of this would be much more complex than what is proposed - and I haven't thought through every detail. I also realize that enum inheritance doesn't address the issue of making creating a certain case private for init purposes.

But I do think it would be a solution that would be easier to work with for both the API client and vendor, and puts any additional work on the API vendor, while allowing the client to use the public enum like any other enum.

2 Likes

I suppose there are two parts to this question. What is an actual motivating use-case for having a non-public case on a public enum ...

  1. ... which cannot be matched on and cannot be created outside a specific context?
  2. ... which can be matched on but cannot be created outside a specific context?

For 1., one use-case is to have an enum with some experimental cases that aren't fully working, but you'd still like to have them alongside other cases for internal use and testing. For example, if you have a non-resilient UI library and you're adding a new style for say borders. If you wanted to do that today, you need to be careful that the experimental style doesn't accidentally end up in the release tag, but it is there on your day-to-day development branch (or you could encode the enum as a struct). With this feature, you can mark the experimental case as internal and rest assured that clients won't be able to mess with it unless they go out of their way to do so.

For 2., it's about preserving invariants. Maybe your enum case has certain invariants that need to be maintained but could be broken by library clients if they're not careful. For example, in rdar://22891351: Swift: Private enum cases, the reporter gives an example of a sum type for pretty printing which needs to maintain some invariants on a particular enum case.

2 Likes

There are two aspects to this:

  1. Adding default:: If you are using a framework with library evolution enabled today, then you already need to add default: cases for non-frozen enums. This pitch is using the same restriction, it's not an additional complication.
  2. Non-public cases in interfaces: Apologies for not spelling this out in the pitch. My current thinking is that:
    a. For interfaces that are generated from swiftmodules (which would be the case for libraries compiled without library evolution), we would display non-public cases to avoid confusion where someone is like "why can't I exhaustively match on this enum even though the generated interface tells me that I've covered all cases?"
    b. For frozen enums (in the presence of library evolution), non-public cases will be emitted into the compiler-synthesized swiftinterface, and could be shown inside an editor to avoid confusion (as above).
    c. For non-frozen enums (in the presence of library evolution), cases are omitted from the compiler-synthesized swiftinterface, so it is not possible to "recover" non-public cases.

The associated values of an enum case form, in essence, an unnamed struct. So you can have certain invariants which need to be enforced between different associated values, analogous to invariants involving different properties of a struct. In the rdar://22891351: Swift: Private enum cases I linked earlier, there's a concrete example of a situation where this comes up in practice.

I think enum inheritance is somewhat of an orthogonal issue to access control. Today, the language supports both access control and inheritance for classes; they serve different purposes. Access control is about "where can I (not) access X from", inheritance is about extending types. Given some of the positive responses in the thread by Matthew, I suspect other people also have use cases where they would like to be able to enforce invariants by limiting the ability to construct certain enum cases.

Is the problem of being able to easily extend enums worth solving? Sure, I agree. However, I think that pivoting this pitch to instead implement inheritance for enums is not a good idea for the following reasons:

  1. The semantics are different in the absence of library evolution: If I have an internal case, changing that to public does not stop client code from compiling, because they can't be relying on exhaustivity. However, if one uses enum inheritance, then adding a new case means that clients will stop compiling because they were (by design) relying on exhaustivity.

  2. I suspect that designing and implementing enum inheritance will be significantly more complicated compared to implementing access control for cases (you can check the current prototype, it's not that big of a change). What you've described is part of the design that needs to be thought through, but there's more. Some examples of complications:

    • How should name collisions in case names be handled across library boundaries?
    • Library evolution is potentially a can of worms:
      • Can you "sink cases" across an inheritance hierarchy? At runtime, a client may need to get the discriminant based on the mangled symbol for the case. Changing which type contains a case would change its mangling which would make this a breaking change; should we add a way to spell that "this case should be mangled as if it were defined in this other type"?
      • How does layout for non-frozen enums (generally boxed today) inheriting from frozen enums (unboxed) work? Do we disallow that? Do we add implicit boxing (similar to creating an existential from a concrete type)? Something else?

    The gist of what I'm trying to get at here is that: inheritance is complicated. I'm not saying it's impossible, but it's a hard problem. In contrast, access control for cases, we can answer most questions by asking "what happens with stored properties" or "what happens for (non-)frozen enums in library evolution". With inheritance, we also have less prior art (amongst mainstream languages, I only know of Scala which has similar functionality).

  3. Encoding access control indirectly using multiple types defeats the purpose of this pitch; this pitch is trying avoid encoding access control as something else. Today, you can already emulate similar behavior with structs to some extent. Exchanging one kind of emulation for a slightly better emulation is not the goal.


Yeah, I originally thought of this, but I wasn't entirely sure if it should go one way or another... I was thinking maybe there's an inconsistency in pattern-matching inside and outside the module, which felt a bit odd. But I think this is a reasonable alternative to the semantics I've proposed and I wouldn't be opposed to doing this.

Well, I may have skipped a few details. What I meant by the "swiftinterfaces are only supported" comment was in terms of "what is consumed by tools". The generated swiftinterface in Xcode is "not supported" in the sense that it is "not supported [for consumption by tools]", it's for people, so we are generally free to change it without breaking anyone. For this reason, I didn't speak about generated interfaces, but I see that was a mistake. :sweat_smile: I've corrected that in an earlier part of the comment in my response to James. Hope that clarifies things.

1 Like

As someone who has the responsibility of designing library interfaces and maintaining the evolution of such I totally understand and approve this type of addition. The audience for this is likely not folks writing a small app but instead those working on a larger program and leaning on the type system to enforce proper encapsulation.

There are definitely a number of times I had wished I was able to do this type of thing before but was limited by the language, and forced to take other, more complicated routes.

7 Likes

IMO, avoiding "inconsistency... inside and outside the module" is simply a non-goal when designing an access control story—the whole point of access control is to introduce an inconsistency across the visibility boundary! :slight_smile:

Okay, understood. Yeah, my concern on this point is a 'soft' concern in a sense that it's just about communicating the non-exhaustiveness (/non-visible-case-ness) of an enum to readers of the generated interface, but point taken that we would be relatively unconstrained from iterating on this in the future.

I think that private cases shouldn't exist as far as other modules are concerned. It should probably be illegal to return an instance of a private case outside the module its defined in. I don't understand what the utility of exposing a private case outside the originating module would be. The semantics of switch outside the originating module shouldn't change since it should be impossible for the private case to exist outside its originating module.

In general extending enums seems like a better way to accomplish this and other goals than this kind of access control.

2 Likes

Maybe consistency is the wrong term for me to use... maybe discoverability is closer to what I had in mind? Here's the scenario. Say this proposal is accepted with the rule that "an internal case with a Never payload behaves like any other internal case for pattern-matching; you still need a default to handle it." Say a developer doesn't know about this proposal and they see a generated interface in the Xcode UI with an internal case reserved(Never) for an enum in a library without library evolution enabled. They might get confused: "if this payload is of type Never, then why am I getting a non-exhaustivity error? Shouldn't that case be ignored for pattern matching similar to how it used to work before (when non-public cases weren't implemented)?"

The first thing someone might try there is they try to create an enum with an internal case locally (i.e. test it within the same module or say inside a Playground). If they do that, they'll notice that pattern matching on such an enum indeed doesn't require using default. This is even more puzzling. Why is there a difference when importing this module compared to using this feature locally? After some thinking, they might go look for a blog post, or the Swift evolution proposal, or they might realize that maybe there's a difference because of things being in different modules and create a multi-module minimal example to try it out.

There's this chain of reasoning they need to follow from "It's my first time seeing this new feature, huh, what's this" to "oh, I understand how this works."

In contrast, if we adopted a different solution to that problem, such as say an attribute like @nonExhaustive, then that's likely a much clearer signal "maybe it's this new attribute affecting things, let me look it up" and they can quickly figure things out.


Could you please take a look at my earlier comment? I've described 3 reasons for why I think extending enums isn't the way to go for this particular issue: it doesn't offer the desired semantics, it is a more complex feature to design and implement and it wouldn't offer the same ergonomics for solving the issues this pitch aims to solve.

1 Like

This seems to me to be a problem that is well-addressed by good diagnostics and fix-its (and a good educational note!). If we have:

// Module A
public enum MyEnum {
  case a
  internal case b(Never)
}

Then from outside the module, I'd expect the following diagnostics (or similar to align with how we talk about these things generally):

switch getMyEnum() {
case .a: break // error: value of type MyEnum may have additional 'internal' cases
}              // fix-it: add a 'default' to handle additional cases


switch getMyEnum() {
case .a: break
case .b: break // error: 'b' is inaccessible due to 'internal' protection level
}              // fix-it: use a 'default' case to match all non-visible cases

Or, if the compiler is unable to see b for some reason (is there a situation where it would show up in the swiftinterface but not be visible to the compiler?):

switch getMyEnum() {
case .a: break
case .b: break // error: type 'MyEnum' has no member 'b'
}              // fix-it: use a 'default' case to match all non-visible cases

It's not as though internal members are a totally foreign concept—I think we can expect some understanding from users of the general access control model that Swift uses.

Or, said differently, if a user is struggling with the idea that internal members behave differently across module boundaries (or struggling with understanding what "module boundary" even means), I don't think that the pattern matching behavior of non-constructible cases is going to be the straw that breaks the camel's back—they're going to be having trouble with much more than just this corner case.

+1 from me.

I would suggest that rather than requiring a default: clause, the use of @unknown clauses should be extended to support this use case, so that even within the same module I could exhaustively switch over public/internal cases of an enum with private/fileprivate cases.

I also don't see any particular reason to restrict the use of CaseIterable and RawRepresentable. There are legitimate reasons why it's useful to be able to refer to private cases indirectly, such as in unit test suites.

5 Likes

In a Swift implementation of TEA, like Point-Free's swift-composable-architecture (“TCA”), it makes a lot of sense to have some or all of a component's Action's cases be internal or fileprivate, because most of the cases are implementation details of the component.

Furthermore, when an Action case has an associated type, the associated type has to have the same visibility as the Action type. So we either expose types that shouldn't be exposed, or we write boilerplate to wrap the real, hidden action type in a public struct.

2 Likes

I didn't mention it explicitly but this use case should work, since @unknown default: is a variation of default:. Put another way, a default: clause is required, but if one adds @unknown, then it would behave analogous to how @unknown behaves in other situations; it should warn about any cases that are accessible but not explicitly written.

I understand the use case for testing. However, conformances for externally defined protocols need to be public, so it gives a very easy way for clients to create/iterate over non-public cases, which may be undesirable. My impression from Matthew's prior pitch was that this was unappealing. Certainly open to removing this restriction though.

One potential workaround if you're using only internal cases is that you could hand-write a retroactive conformance in the test suite after doing a @testable import; that would prevent library clients from accessing the conformance, while allowing the conformance to be used for easier testing. That's still more clunky though than the alternative of having compiler-synthesized conformances.

2 Likes

I think this feature is long overdue. One example of a "real-world" use case that could guide our design might be to look at Apple SDK enums with unexposed cases; UIKeyboardType is a notorious example. Users need to be able to accept private values of the enum and pass them through their code in order to interop correctly with the SDK, and they arguably need to be able to generate all valid cases too in order to exhaustively test their code, even though they should never directly produce specific values of the private cases.

15 Likes

I'm thinking about matching in switch statements for consumers. If I have it right, in the current design you'd have to have a default or case _ in the switch to catch any case that you were matching on that was not accessible to the consuming code.

But what if you needed for some reason to dispatch differently in consumer code based on the accessibility level of the enum cases?

(I guess I have this dogma that switch should not have a default if at all possible and I'm trying to enforce a requirement that all accessible cases are covered without default.)

What about this as a straw man syntax?:

switch x {
   case .ok(let y): f(y)
   case .lessOK(let y): g(y)
   case fileprivate: h(x)
   case private: fatalError("private enum case")
   default: fatalError("enum case that is not private, file private, .ok, or .lessOK")
}

The idea is that you could separate out the various kinds of access-controlled enum cases rather than lumping them into the default which could also include accessible cases.

Working through it, I don't think you need or can allow bindings, the x that you're switching on is available and has the same type, and you would not be able to bind its internals if you don't have access, but you could pass it through.

The advantage to this would be that you could code an exhaustive switch without a default and be sure that all the accessible cases were covered. In the example above if .ok and .lessOK were the only accessible cases and all the others were private or fileprivate then you could remove the default.

The case private would behave like the unwritable case .private1... .privateN clause (and similarly for the other access modifiers).

The weird thing about this is that it adds gradations of non-access to the consuming code, where normally things are accessible or not and the consumer has no knowledge of anything not accessible, which is good.

Another possibility would be:

switch x {
   case .ok(let y): f(y)
   case .lessOK(let y): g(y)
   inaccessible: fatalError("inaccessible enum case")
   default: fatalError("enum case that is not private, file private, .ok, or .lessOK")
}

with a new inaccessible keyword to cover any enum cases that are not accessible. That would get the consuming code the ability to take out the default as long as it covered all the accessible cases.

I'm not sure @unknown default quite covers this, although we could extend it possibly.

Alternately something like @inaccessible default could work although I guess I would like to move more in the direction of fewer @-signs.

That's correct.

Allowing such runtime introspection defeats the purpose of abstraction. The library author is free to change the access level; changing a case in a library from fileprivate to private shouldn't risk breaking clients.

Moreover, it's not clear why you'd want to make this distinction since there's not much you can do with cases that are inaccessible.

This is not a tenable position as libraries need to be able to add cases to enums without stopping clients from compiling; this is made explicit in library evolution as non-frozen enums, as well as in this pitch.

You can use @unknown default so that you get a warning when new public cases are added. I do not think adding a separate @inaccessible default is worth it, because its semantics would be very close to @unknown default as discussed in the pitch and subsequent comments.

3 Likes