Access control for enum cases

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.

4 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

+1 but make the introduction on access control modifiers be explicit the moment it is added to an enum case. Otherwise this is going to be too confusing. In Swift the absence of a control modifier usually means internal but that is not the case for Enums because the cases are public by default when the enum is public so if we introduce the ability of lowering this default for enums then all the cases should be explicit even internal cases.

3 Likes

Could you clarify what you mean with an example or two? Are you saying that we should have a breaking change where:

public enum E { case e }

would mean that e is internal (for consistency with other declarations) by default instead of public (current default)? Is the suggestion that we should warn on this, and suggest that people write public case instead? Or something else?

This pitch doesn't propose changing the behavior for existing enums and their cases, those would be treated based on the access control rules we have today.

I think the suggestion is for diagnostics like the following

public enum E {
  case e // OK, existing behavior
}

public enum F {
  case f1 // error: enum with internal case must specify visibility of all cases explicitly
         // fix-it: insert 'public '
  internal case f2
}

public enum G {
  public case g1 // OK
  internal case g2
}

I think I agree that the deviation from the usual "default access level is internal for members of public types" rule may justify forcing authors to be a bit more explicit. We could also have a rule that if any enum cases have access specifiers, then the default access level for all other cases becomes internal. I.e.:

public enum H {
  public case h1
  case h2 // this case is internal
}

Then the only exception to the "normal" access control behavior is "for an enum with no case-level access specifiers, all cases are as visible as the enum declaration itself."

3 Likes

This is precisely what I meant. Thank you.

@mayoff, but it will force you to always write default in top-level switches, at that case you probably may create an equatable struct for concrete Actions, and a static factory for this struct. Anyway this feature will be useful for unidirectional architectures in general

In TCA:

  • we usually don't switch at all over a component's Action type except inside that component;
  • when we do switch over some other component's Action type, we usually only care about a small number of its cases and have a wildcard case to ignore the others.

For example, in this DailyChallengeFeature module, the enum DailyChallengeAction has cases wrapping enum DailyChallengeResultsAction and enum NotificationsAuthAlertAction from other modules. The dailyChallengeReducer

  • use a wildcard for all wrapped DailyChallengeResultsAction values;
  • matches two cases of NotificationsAuthAlertAction and uses a wildcard for any others.

Hidden cases will not increase (or decrease) the need for wildcard matches in a TCA app.

There's something off to me about this example, and it's related to the @frozen @unknown default attribute. That was also added for a similar reason as this pitch (inaccessible enum cases that are linked to the consumer's binary without their knowledge), and it feels like if we are still adding stuff to solve that class of problem, we didn't do a good job solving it the first time around.

One of the alternative approaches to the @frozen @unknown default problem was making these objective c enums into structs in the overlay layer, and it seems to me that that would solve this problem too. As long as ~= is defined and the init is private, it would act exactly like an "unsealed" or nonexhaustive enum, giving us all the desired behavior with very little client code changes. It does seem like we're moving towards two general groups of enums — exhaustive and non-exhaustive.

What currently happens if a user is handed an unexposed UIKeyboardType case and the user tries exhaustively switch over it? Trap? It hits the @frozen @unknown default case?

I think this should be a first class part of this proposal — I would be surprised and upset if I saw some enum cases in a generated header, tried to switch over it, and then got hit with an error that I hadn't handled all of the cases.

7 Likes

I'm not sure that's the case. @frozen means the opposite—there are no cases hidden from public API, and the library promises never to add more, so clients can safely exhaustively match the cases.

4 Likes

Right, duh. I confused myself, I meant @unknown default.

I agree with other posters in this thread that it's natural to use @unknown default to refer to private cases, in addition to its current use for referring to yet-to-exist cases. The proposal at hand fills in the gap that Swift does not allow for private cases today (despite ObjC being able to emulate the pattern, and Apple's frameworks often making use of this ability).

9 Likes

I'm not sure it makes sense to hide private cases completely, as it annoyingly requires the use of an @unknown default with little context, and I'm not sure hiding the name of a case has much value anyway. We get all of the same functional benefits by just hiding the initializer, just as you would a private static func. Since Swift has already established an enum case :: static func equivalency in protocol conformances, why not just limit the proposal to that for private case foo?

Terms of Service

Privacy Policy

Cookie Policy