Should you ever have a `.none` case in an enum?

i have an enum that looks like

enum Subgroup 
{
    case none 
    
    case one     (Diacritic)
    case many(Set<Diacritic>)
}
extension Subgroup
{
    mutating 
    func insert(_ next:Diacritic)
    {
        switch self 
        {
        case .none: 
            self = .one(next)
        case .one(next): 
            break
        case .one(let first): 
            self = .many([first, next])
        case .many(var diacritics):
            self = .none 
            diacritics.insert(next)
            self = .many(diacritics)
        }
    }
}

the none case is really useful when doing things like populating a dictionary, because you can do things like:

groups[key, default: .none].insert(value)

instead of having to fiddle around with Dictionary.Index. it also makes it possible to implement Subgroup.insert(_:) in amortized O(1) without needing extra underscored cases like _modifying which would basically just be none with an underscore.

but having a none case when Optional<T> exists just doesn’t feel very swifty.

should you ever have a none case in an enum?

4 Likes

Since we don't have namespaces for disambiguation, and fake them with enums, it seems super Swifty to me. :person_gesturing_ok:

2 Likes

I'd say (in the order of personal preference):

  1. Optional's "none" should have been named "nil" to begin with, as it is highly less likely someone would want to have the name "nil" in their enums compared to "none". Plus #2.
  2. whether it's "none" or "nil" - it should be treated as a reserved word and disallowed in "normal" enums (everything but "Optional") and cause the corresponding compilation error.
  3. provided it is allowed in the language perhaps it could still be prohibited by a third party linter.
  4. provided none of the above - just remember not using it.

Sure. Besides the reason you've already listed:

  1. As a reader of code, I feel that there's a subtle semantic distinction between Optional<T> and T with a .none case:

    • T.none: a meaningful element within the domain of T; intentionally present
    • Optional<T>.none: outside of the domain of T; not immediately clear why Optional<T> value can be missing (accidental? intentional? the result of bad data? etc.) — Optional can be pretty overloaded in its meaning

    T.none gives me confidence as a reader that it's a special and intentional value

  2. Keeping these values within the domain of your type also helps keep your code more intentional on the type. e.g., you can write extensions on T instead of Optional<T>, hold collections of T as opposed to Optional<T>, etc. Potential for less noise, depending on the specifics of how it's used

From the examples you give, I'd say that I'd prefer Subgroup have a .none case than defer to Optional for it.

26 Likes

I agree with @itaiferber, it is perfectly valid and even reasonable in certain scenarios to have .none as a case. Especially point 1 is true, it's a question of which domain you're thinking in or on what "level" of your data you are.

The Subgroup example to me even looks like a perfectly fine specific case where that makes sense. Whatever holds a Subgroup always has a value of it, it might just be "empty" or "not doing anything" or whatever you want to call it (and why would "none" be a bad word for that?). A bit like a more specialized set (instead of just empty or not, it has "no" or .none elements, .one element, or .many elements).

Sounds fine to me, if anything there's some consistency with existing naming for a similar concept. I believe in functional/category theory jargon you could say that the type you describe would be isomorphic to a composite type of Optional<SubgroupWithoutNone>, but the overhead of dealing with the nested type makes the composite option much more cumbersome.

For example, as this type qualifies as a functor/monad, keeping it flat means you can define map/flatMap for it and it should all work as expected. Trying to do that with the composite type would be pretty nasty as you'd have map/flatMap for the outer Optional, then you'd still need to write map/flatMap for your inner type, plus extra complication of dealing with a composite type at the call site.

I would make the same choice as you have.

1 Like

I think it's valid to have a none case, but for practical reasons I'd choose another word for it such as empty or zero. Otherwise it's a bit too easy to introduce bugs whenever you wrap the enum inside an optional.


It'd be It's nice if the compiler couldcan diagnose those ambiguities and emit a warning at the point of use. For instance:

let subgroup: SubGroup? = .none
// warning: .none is resolved to Optional.none
//  did you mean Subgroup.none
//  did you mean Optional.none, aka nil

And to remove the warning you have to specify the exact .none you want or use nil.

Edit: Haha, seems like this warning is already there. I just remember the past where it wasn't.

1 Like

In a similar instance I sometimes use unknown, especially when I'm dealing with data that might be coming from a backend API.

enum AccountType: String {
case checking
case savings
case loan
case unknown
}

If during deserialization we get a new account type that's been added after the app has shipped, then it's mapped to unknown so that the code is forced to handle the possibility.

    public init(from decoder: Decoder) throws {
        guard let rawValue = try? decoder.singleValueContainer().decode(String.self) else {
            self = .unknown
            return
        }
        self = AccountType(rawValue: rawValue) ?? .unknown
    }

I've been bitten too many times by the back end people deciding to change the contract without consulting the client. Or even if they do, and we account for it in the next version we still have older versions of the apps in the wild to contend with.

1 Like

Take this example:

enum Good {
    case one
    case two
    case three
}

enum Bad {
    case none
    case one
    case two
    case three
}

func foo(good: Good?, bad: Bad?) {
    
    switch good {
    case .one: print("one")
    case .two: print("two")
    case .three: print("one")
    case .none: print("none")
    }

    switch bad { // Error: Switch must be exhaustive
    case .one: print("one")
    case .two: print("two")
    case .three: print("three")
    case .none: print("none") // Assuming you mean 'Optional<Bad>.none'
    case Optional.none: print("none") // Warning: Case is already handled by previous patterns
    }

    switch bad {
    case .one: print("one")
    case .two: print("two")
    case .three: print("three")
    case Bad.none: print("none") // Error
    case Optional.none: print("none")
    }
}

There's an obvious way of fixing this by treating nil separately before the switch, but note that "Good" way of doing switch was totally valid, and it got suddenly broken with the introduction of "none" case.

If you change from none to nope - the problem goes away (but if the app was working before it might be doing something different now, see just below). And equally worrying, the above snippet aside, if you see a case nope in your peer's code, and think "it is a good idea to change nope to none" - the code would probably compile, the warning could be missed (unless warnings are treated as errors which many companies can't afford) and the app starts to behave differently.

enum E {
    case one
    case none // renamed from "nope". what can possibly go wrong.
}

var x: [Int: Bad] = [1 : .one, 2 : .none] // was nope

... some thousands line away in a different file in a project with a few warnings already

let a = x[1] == .one
let b = x[2] == .none // was nope

I wish there was a way to promote that warning into an error (without "treating all warnings errors").

1 Like

This works; you have to handle the Optional explicitly for all cases:

enum Bad { case none, one, two, three }

func foo(_ bad: Bad?) {
    switch bad {
    case .none: print("Optional.none")
    case .none?: print("Bad.none")
    case .one?: print("Bad.one")
    case .two?: print("Bad.two")
    case .three?: print("Bad.three")
    }
}

If I'm switching over an optional value, I think it's a good idea to always include the ? in cases instead of relying on the implicit conversion, to avoid these kinds of issues.

7 Likes

Good to know. Although it raises a further couple questions:

If I can do it in switch:

switch bad {
case .one: break
case .some(.one): break
case .one?: break

(whereas the last line syntax looks a bit awkward to me but perhaps that's just me) why can't I do it in other contexts:

let a: Bad? = .one
let b: Bad? = .some(.one)
let c: Bad? = .one? // Error

Besides the "it's a good idea to always include the ? in cases instead of relying on the implicit conversion" sounds like a guideline, similar and peer to, say, "never use none in enums". Questionable which of these guidelines is better. Let's assume the first is better, in that case I'd suggest we could "enforce" that guideline making it a law and issuing the corresponding errors (if cases are used without ?) and later on remove the relevant chunks from the swift compiler that supports that implicit conversion.

It's only valid in case statements.

let a: Bad? = .one
if case let c? = a { }

The more common if let (which doesn't use a ?) is just sugar for that.

If Optional didn't allow assignment via its Wrapped (i.e. .one and .some(.one) are equivalent), that would make all of this more clear, but it also would make the language harder to use.

2 Likes

i agree, i have a convention of always writing Optional<T>.none as nil and writing pattern matches with the trailing ?, so i have never run into this problem either.

2 Likes

Same for me too.