This article is also available in Chinese.
Last year I wrote a post about how adding simple optional properties to your classes is the easy thing to do when you want to extend functionality, but can actually subtly harm your codebase in the long run. This post is the spiritual successor to that one.
Let’s say you’re designing the auth flow in your app. You know this flow isn’t going to be simple or linear, so you want to make some very testable code. Your approach to this is to first think through all the screens in that flow and put them in an enum:
enum AuthFlowStep {
case collectUsernameAndPassword
case findFriends
case uploadAvatar
}
Then you put all your complicated logic into a single function that takes a the current step and the current state, and spits out the next step of the flow:
func stepAfter(_ currentStep: AuthFlowStep, context: UserState) -> AuthFlowStep
This should be very easy to test. So far, so good.
Except — you’re working through the logic, and you realize that sometimes you won’t always be able to return a AuthFlowStep
. Once the user has submitted all the data they need to fully auth, you need something that will signal that the flow is over. You’re working right there in the function, and you want to want to return a special case of the thing that you already have. What do you do? You change the return type to an optional:
func stepAfter(_ currentStep: AuthFlowStep, context: UserState) -> AuthFlowStep?
This solution works. You go to your coordinator and call this function, and start working with it:
func finished(flowStep: AuthFlowStep, state: UserState, from vc: SomeViewController) {
let nextState = stepAfter(flowStep, context: state)
When we get nextState
, it’s optional, so the default move here is to guard
it to a non-optional value.
guard let nextState = stepAfter(flowStep, context: state) else {
self.parentCoordinator.authFlowFinished(on: self)
}
switch nextState {
case .collectUsernameAndPassword:
//build and present next view controller
This feels a bit weird, but I remember Olivier’s guide on pattern matching in Swift, and I remember that I can switch
on the optional and my enum at the same time:
func finished(flowStep: AuthFlowStep, state: UserState, from viewController: SomeViewController) {
let nextState = stepAfter(flowStep, context: state) // Optional<AuthFlowStep>
switch nextState {
case nil:
self.parentCoordinator.authFlowFinished(on: self)
case .collectUsernameAndPassword?:
//build and present next view controller
That little question mark lets me match an optional case of an enum. It makes for better code, but something still isn’t sitting right. If I’m doing one big switch, why am I trying to unwrap a nested thing? What does nil
mean in this context again?
If you paid attention to the title of the post, perhaps you’ve already figured out where this is going. Let’s look at the definition of Optional
. Under the hood, it’s just an enum, like AuthFlowState
:
enum Optional<Wrapped> {
case some(Wrapped)
case none
}
When you slot an enum into the Optional
type, you’re essentially just adding one more case to the Wrapped
enum. But we have total control over our AuthFlowStep
enum! We can change it and add our new case directly to it.
enum AuthFlowStep {
case collectUsernameAndPassword
case findFriends
case uploadAvatar
case finished
}
We can now remove the ?
from our function’s signature:
func stepAfter(_ currentStep: AuthFlowStep, context: UserState) -> AuthFlowStep
And our switch statement now switches directly over all the cases, with no special handling for the nil cases.
Why is this better? A few reasons:
First, what was once nil
is now named. Before, users of this function might not know exactly what it means when the function returns nil. They either have to resort to reading documentation, or worse, reading the code of the function, to understand when nil might be returned.
Second, simplicity is king. No need to have a guard
then a switch
, or a switch
that digs through two layers of enums. One layer of thing, always easy to handle.
Lastly, precision. Having return nil
available as a bail-out at any point in a function can be a crutch. The next developer might find themselves in an exceptional situation where they’d like to jump out of the function, and so they drop a return nil
Except now, nil
has two meanings, and you’re not handling one of them correctly.
When you add your own special cases to enums, it’s also worth thinking about the name you’ll use. There are lots of names available, any of which might make sense in your context: .unknown
, .none
, .finished
, .initial
, .notFound
, .default
, .nothing
, .unspecified
, and so on. (One other thing to note is that if you have a .none
case, and you do happen to make that value optional, then whether it will use Optional.none
or YourEnum.none
is ambiguous.)
I’m using flow states as a general example here, but I think the pattern can be expanded to fit other situations as well — anytime you have an enum and want to wrap it in an optional, it’s worth thinking if there’s another enum case hiding there, begging to be drawn out.
Thanks to Bryan Irace for feedback and the example for this post.