API Additions to Optional (filter, ifSome, ifNone, peek)

Over a year later, I finally got around to cooking up a proposal from this thread. These seem quite popular, seeing as the original post has accumulated over 1.6K views. Let me know what you guys think!

API Additions to Optional

During the review process, add the following fields as needed:

Introduction

This proposal puts forth a few simple additions to Optional. They don't do anything that you couldn't before, but the improve the ergonomics of Optional, much like map and flatMap. All the names are placeholders, we can bike shed over them later.

They can be considered individually, as they don't rely on eachother.

Swift-evolution thread: Discussion thread topic for that proposal

Table of Contents

  1. Optional.filter
  2. [Optional.ifSome / Optional.ifNone](#Optional.ifSome / Optional.ifNone)
  3. Optional.peek

Optional.Filter

Motivation

I often find myself needing to map to conditionally map an optional to nil. E.g.

let file = try readFile().flatMap{ try fileValidator.isValid($0) ? $0 : nil }

This code:

  • is long(er)
  • is less obvious (people are generally not very familiar with Optional.flatMap)
  • prevents you from passing in functions as closures directly (as I do with fileValidator.isValid below)
  • gets messy with duplicate try if the predicate throws

Proposed solution

A new API on Optional, Optional.filter, which would let you write:

let file = try readFile().filter(fileValidator.isValid)

This has strong precedence in other languages:

On the flip side, it's absent in C#, which has a pretty bare bones Nullable type, which I don't think we should be taking guidance from.

Detailed design

extension Optional {
    /// If `self` is non-nil, and satisfies the predicate, return `self`, otherwise return `nil`.
    ///
    ///     func isEven(_ i: Int) -> Bool { return i % 2 == 0 }
    ///     
    ///     let nilInteger: Int? = nil
    ///     let one: Int? = 1
    ///     let two: Int? = 2
    ///     
    ///     print(nilInteger.filter(isEven) as Any) // prints "nil"
    ///     print(one.filter(isEven) as Any) // prints "nil"
    ///     print(two.filter(isEven) as Any) // prints "Optional(2)"
    ///
    /// - Parameter shouldKeepPredicate: A closure that takes the unwrapped value,
    ///   and decides to either return `self` (if `true`), or `nil`.
    /// - Returns:
    ///    * `nil` if `self` is `nil`
    ///    * `nil` if `self` is non-`nil` and `shouldKeepPredicate` evaluates to `false`
    ///    * `self` if `self` is non-`nil` and `shouldKeepPredicate` evaluates to `true`
    func filter(_ shouldKeepPredicate: (Wrapped) throws -> Bool) rethrows -> Optional {
        return try flatMap { try shouldKeepPredicate($0) ? self : nil }
    }
}

Alternatives Considered

Naming

  1. discard(if:)
  2. ...

Optional.ifSome / Optional.ifNone

Motivation

Often times, you want to do something with the value with an optional, but only if it's not nil:

let error: Error? = someError()
if let error = error { Logger.log(error) }
  • It requires you duplicate the name of the variable, which typically drives people to use arbitrary short names (e.g. e)
  • Prevents you from passing in functions as closures directly

Another problem is that long optional chains declare a bound variable right at the start, but use it after the end. You can also never omit the name, even if it's low value enough to not justify its existence (hence the whole motivation behind implcit closure parameters, like $0, $1, ...)

if let result = Something() // "result" variable defined all the way up here
.doSomething()
.map { $0.something }
.filter({ $0.isValid }) { // can't use trailing closure syntax on last line
    use(result) // result used all the way down here
}
else {
     handleNil()
}

Proposed solution

New APIs on Optional, Optional.ifSome and Optional.ifNone, which would let you write:

let error: Error? = someError()
error.ifSome(then: NetworkLogger.log)
// or
error.ifSome { NetworkLogger.log($0) }

and

Something()
    .doSomething()
    .map { $0.something }
    .filter { $0.isValid } // can use trailing closure here
    .ifSome(then: { result in use(result) }) // result declared close to where it's used
    .ifNone(then: handleNil)

The ifSome portion can even be simplified uses existing closure syntactic sugar:

  • ifSome(then: { use($0) })
  • ifSome { use($0) }
  • ifSome(then: use)

This has some precedence in other languages:

On the flip side, it's absent in Rust, Julia, OCaml, C#, F# and Haskell.

Detailed design

The goal syntax has 3 valid forms:

  1. Check only for the some case:

     anOptional.ifSome { useValue($0) }
    
  2. Check for the nil case

     anOptional.ifNone { doSomethingInTheAbsenceOfAValue() }
    
  3. Check first for some case, then for nil case:

     anOptional
         .ifSome { useValue($0) }
         .ifNone { doSomethingInTheAbsenceOfAValue() }
    

To prevent a mess of calls to ifSome and ifNone, three rules are proposed:

  1. You cannot check for the some case twice:

     anOptional
         .ifSome { useValue($0) }
         .ifSome { useValue($0) } // 💥 error: value of type 'Optional<T>.IfSomeResult' has no member 'ifSome'
         .ifNone { doSomethingInTheAbsenceOfAValue() }
    
  2. You cannot check for the nil case twice:

     anOptional
         .ifSome { useValue($0) }
         .ifNone { doSomethingInTheAbsenceOfAValue() }
         .ifNone { doSomethingInTheAbsenceOfAValue() } // 💥 error: value of tuple type 'Void' has no member 'ifNone'
    
  3. If checking for both the some and nil case, ifSome must come before ifNone

     anOptional
         .ifNone { doSomethingInTheAbsenceOfAValue() }
         .ifSome { useValue($0) } // 💥 error: value of tuple type 'Void' has no member 'ifSome'
    

Optional has two cases, some(wrapped) and none. Because they're both 4 characters long, using some/none has the asthetically pleasing effect that they makes your code line up nicely.

Implementation

extension Optional {
    /// An enum used to ensure that `ifNone` is never called before `ifSome`.
    enum IfSomeResult {
        case some
        case none
        
        func ifNone(_ closure: () throws -> Void) rethrows -> Void {
            switch self {
                case .some: return
                case .none: try _ = closure()
            }
        }
    }
    
    @discardableResult
    func ifSome(then closure: (Wrapped) throws -> Void) rethrows -> IfSomeResult {
        if let wrapped = self {
            try _ = closure(wrapped)
            return IfSomeResult.some
        }
        else {
            return IfSomeResult.none
        }
    }
    
    func ifNone(then closure: () throws -> Void) rethrows -> Void {
        if case nil = self { try _ = closure() }
    }
}

Alternatives considered

Loosen the rules: Allow multiple closures for each case

I don't see any benefit to syntax like this

    anOptional
        .ifSome { useValue($0) }
        .ifSome { useValue($0) }
        .ifNone { doSomethingInTheAbsenceOfAValue() }
        .ifNone { doSomethingInTheAbsenceOfAValue() }

as opposed to simply:

    anOptional
        .ifSome {
            useValue($0)
            useValue($0)
        }
        .ifNone {
            doSomethingInTheAbsenceOfAValue()
            doSomethingInTheAbsenceOfAValue()
        }

Loosen the rules: Allow ifNone before ifSome

I don't see any benefits to allowing this. If the previous rule is also removed, then people can interweave ifSome and ifNone, which would make for quite a mess!

Alternate name: ifNil

Although more people are familiar with nil (because it is used pretty pervasively in place of Optional.none), it's only 3 letters long, which has the effect of staggering your code:

    anOptional
        .ifSome { useValue($0) }
        .ifNil { doSomethingInTheAbsenceOfAValue() }

as compared to:

    anOptional
        .ifSome { useValue($0) }
        .ifNone { doSomethingInTheAbsenceOfAValue() }

However, I think people are less aware of it (because nil is used pretty pervasively).

Alternate name: ifEmpty

"Empty" is not a precedented term for an optional with no value. It's also 5 characters long, causing it to have tge same staggering issue as "nil".

"Just use Optional.map

This works, but...

  • it requires that you manually discard the result, because Optional.map does not have a @discardableResult.

  • it has no "else" counterpart.

  • it's a "misuse" of what that API is intended for.

      let _ = anOptional.map { useValue($0) }
    

ifSome(then:else:)

People have suggested combining the then and the else closures as parameters to a single function call, like so:

extension Optional {
    func ifSome(
        then thenClosure: (Wrapped) throws -> Void = {_ in},
        else elseClosure: () throws -> Void
    ) rethrows {
        if let wrapped = self { try thenClosure(wrapped) }
        else { try elseClosure() }
    }
}

This is problematic for a few reasons:

  1. It doesn't "feel" as good, in any of its vareints. Compare:This isn’t as nice as my original .ifSome, ifThen, because of the look/feel when using traling closures:

     someOptional.ifSome {
         print("Optional had a value! \($0)")
     }
     .ifNone {
         print("Optional was nil")
     }
    

    VS.

     someOptional.ifSome(then: {
         print("Optional had a value! \($0)")
     },
     else: {
         print("Optional was nil")
     }) // <-- "})", yucky
    

    or

     someOptional.ifSome(then: {
         print("Optional had a value! \($0)"
     }) // normal trialing closure, `else` keyword is dropped off, and key context is missing
     {
         print("Optional was nil")
     }
    
  2. Defaulted argument functions cause some unexpected or undesirable behaviour

    • Defaulted argument can throw, so omitting a closure can't be done without an (otherwise unecessary) try keyword

        // 💥 error: call can throw but is not marked with 'try'
        // ⚠️ note: call is to 'rethrows' function, but a defaulted argument function can throw
        someOptional.ifSome(then: { 
            print("Optional had a value! \($0)")
        })
      
      
        // 💥 error: call can throw but is not marked with 'try'
        // ⚠️ note: call is to 'rethrows' function, but a defaulted argument function can throw
        someOptional.ifSome(else: { 
            print("Optional was nil")
        })
      
    • Even when we added the necessary try keywords, it still leads to some undesired results:

        // 💥 error: contextual closure type '() throws -> Void' expects 0 arguments, but 1 was used in closure body
        try someOptional.ifSome { 
            print("Optional had a value! \($0)")
        }
      
        // ✅ Compiles, but calls the `else` case, which doesn't match expectations given what's written.
        try someOptional.ifSome { 
            print("Optional was nil")
        }
        // ✅ Compiles, but it's a non-sensical no-op
        try someOptional.ifSome()
      

    Fixing these two issues would require not using default args, and hand-write 3 similar functions instead:

     extension Optional {
         func ifSome(
             then thenClosure: (Wrapped) throws -> Void,
             else elseClosure: () throws -> Void
         ) rethrows {
             if let wrapped = self { try thenClosure(wrapped) }
             else { try elseClosure() }
         }
         
         func ifSome(then thenClosure: (Wrapped) throws -> Void) rethrows {
             if let wrapped = self { try thenClosure(wrapped) }
         }
         
         func ifNone(then closure: () throws -> Void) rethrows {
             try closure()
         }
     }
    
     let someOptional: Int? = 1
    
     someOptional.ifSome(then: { 
         print("Optional had a value! \($0)")
     }, else: { 
         print("Optional was nil")
     })
    
     someOptional.ifSome(then: { 
         print("Optional had a value! \($0)")
     })
    
     someOptional.ifNone(then: { 
         print("Optional was nil")
     })
    

    Ultimately, I don't see any benefit to combining these two closures into one function call.

Optional.peek

Motivation

Sometimes you might have a stream of operations on an Optional, and for debugging purposes you would like to see what's happening in the middle of the stream. You could break the stream up into multiple parts, but often times that forces you into assigning low-value names, and repeat error handling code. Suppose you had this code below, and wanted to see where you nil came from.

guard let rating = readLine()?
    .map(Int.init)
    .flatMap({ (1...5).contains($0) ? $0 : nil }) else {
        fatalError("The input was not an integer between 1 and 5!") // Fatal error just for the example
    }

You would be forced to break apart all the peaces, and repeat most of your error handling code. In this case, it gives the opportunity to make more precise error messages, but these really don't add much value beyond just "The input was not an integer between 1 and 5!".

guard let let userInputRating = readLine() else {
    fatalError("There was no input!")
}

guard let userInputRatingInt = userInputRating.map(Int.init) else {
    fatalError("The input was not an integer!")
}

guard let validUserInputRating = userInputRatingInt.flatMap({ (1...5).contains($0) ? $0 : nil }) {
    fatalError("The input is an integer (\(userInputRatingInt)), but not between 1 and 5!")
}

Proposed solution

Add an API to Optional, Optional.peek, which lets you "peek" into what the value is, at that point in the stream. It does not give you the ability to mutate the value, because other APIs let you do that more clearly. Calls to peek can easily be added during a debugging sessions, and cleaned up afterwards, without forcing large rewrites of that area of code:

guard let rating = readLine()?
    .peek { triggerBreakPoint($0) }
    .map(Int.init)
    .peek { triggerBreakPoint($0) }
    .flatMap({ (1...5).contains($0) ? $0 : nil })
    .peek({ triggerBreakPoint($0) }) else {
        fatalError("The input was not an integer between 1 and 5!") // Fatal error just for the example
    }

Detailed design

extension Optional {
    /// Inspect the value in the optional, passing it along for further processing
    /// - Parameter closure: A closure that's called with `self`.
    /// - Returns: `self`.
    func peek(_ closure: (Optional) throws -> Void) rethrows -> Optional {
        closure(self)
        return self
    }
}

Alternatives considered

Alternate names:

  1. Optional.tap
  2. ...

Source compatibility

This is an additive change.

Effect on ABI stability

This is an additive change.

Effect on API resilience

This is an additive change.

7 Likes

For the ifSome/ifNone part, can you explain what improvement it brings compared to the already existing .map and ??.

I mean, if you have an optional item, you can do:

myOptional.map { use($0) } ?? handleNil()

I do understand that this may not be super explicit and that you want use and handleNil to return compatible types.

You answered your own question. Those are two very good reasons, I see each of them individually as "disqualifying," let alone both simultaneously

filter makes sense to me, but ifSome and ifNone merely wrap control flow we already have into methods, and peek seems like something you’d want to do with any type, not just Optional.

16 Likes

Right, but .map / ?? are more powerful and introducing overloaded operators would just make them harder to discover. I think here you take the risk of having ifSome / ifNone misused.

eg:

var value: SomeType
myOptional.ifSome { value = someValue }
  .ifNone { value = someOtherValue }

This is both an API misuse and inefficient.

I think the point the OP is getting at is a more "functional" option to the control flow based API.

Personally I'm not sure this passes the bar of value to all users to justify adding to the Standard Library. That said, if this existed, when I was using functional programming styles, I'd probably use this to keep consistent chaining with map, filter etc. On the flip side though, as @fbruneau points out, mutation of the values would seem to make more sense inside a map function.

1 Like
  1. Looks good.
  2. I don't think it's worth it to limit what you call misuse here. At worst the user is making their code slightly more confusing, nothing more. This sounds like something a linter could handle.
  3. Seems very niche, but useful in certain scenarios.

I would also encourage the exploration of converting optionals to throwing expressions and Result values. Being able to convert Optional handling into Error handling can be very powerful and convenient. Besides which, we can already go the other way.

4 Likes

Thank you for this proposal. Optional.filter is very promising. Not sure about ifSome and ifNone, these names are a bit weird, sorry.

2 Likes

Yep. But so does forEach. I don't see this as an issue. I think it adds enough value to warrant having it.

Oh true. Ruby's tap is defined on Object, one of their most general types. I wish we could extend Any :confused:

I don't think opportunity for misuse is a good reason to discourage a design, except for the most severe cases. It's easy to come up with examples of misused APIs, but I don't think that's an issue unless the API design makes it really easy to run into those anti-patterns.

1 Like

Did any better names come to mind?

For example, whenSome and whenNone. By the way, I often miss something like Optional.forEach, which is basically Optional.whenSome right? But for whenNone I actually like the standard if optional == nil {}.

Ooo, whenSome or whenNone are good names, I think I like them more than the if variants.

Yep. But I'm not a fan of the name forEach, because it makes a rather forced parallel between optionals and collections. It's just not how people usually talk.

My only gripe with that is that it clamps an optional expression on either side (if before, and == nil {...} after), which doesn't look as good for long chains

Something()
    .doSomething()
    .map { $0.something }
    .filter { $0.isValid } // can use trailing closure here
    .ifSome(then: { result in use(result) }) // result declared close to where it's used
    .ifNone(then: handleNil)

versus

if Something()
    .doSomething()
    .map { $0.something }
    .filter { $0.isValid } == nil {
    handleNil()
}

I anticipate a suggesting to break that expression down into multiple let bindings, but the issue there is that often times you deal with multiple stages of a half baked result, which makes you end up with rather poor/arbitrary names that don't really add much value, anyway, like userInputStr, userInputInt (after mapping to Int), unwrappedUserInputInt, userInputIntWithinRange, etc.

2 Likes

Regarding peek I can see this being useful for any type where chaining dot-syntax is encouraged. Why not something like:

public protocol Peekable { }
extension Peekable {
    /// Inspect the value in the optional, passing it along for further processing
    /// - Parameter closure: A closure that's called with `self`.
    /// - Returns: `self`.
    func peek(_ closure: (Optional) throws -> Void) rethrows -> Optional {
        closure(self)
        return self
    }
}

extension Optional: Peekable {}

Then we could also add it to things like

extension CGRect: Peekable {}

Now that I've thought more about it, I would really like this to be on Any. If only that were possible.

How about forSome and forNone to match forEach? :crazy_face:

1 Like