Skip to content

Instantly share code, notes, and snippets.

@erica
Last active February 26, 2022 04:51
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save erica/5a26d523f3d6ffb74e34d179740596f7 to your computer and use it in GitHub Desktop.
Save erica/5a26d523f3d6ffb74e34d179740596f7 to your computer and use it in GitHub Desktop.

Introducing an error-throwing nil-coalescing operator

Introduction

Swift's try? keyword transforms error-throwing operations into optional values. We propose adding an error-throwing nil-coalescing operator to the Swift standard library. This operator will coerce optional results into Swift's error-handling system.

This proposal was discussed on the Swift Evolution list in the name thread.

Motivation

Any decision to expand Swift's set of standard operators should be taken thoughtfully and judiciously. Moving unaudited or deliberately non-error-handling nil-returning methods and failable initializers into Swift's error system should be a common enough use case to justify introducing a new operator.

Detail Design

We propose adding a new operator that works along the following lines:

infix operator ??? {}

func ???<T>(lhs: T?, @autoclosure error: () -> ErrorType) throws -> T {
    guard let value = lhs else { throw error() }
    return value
}

The use-case would look like this:

do {
    let error = Error(reason: "Invalid string passed to Integer initializer")
    let value = try Int("NotANumber") ??? InitializerError.invalidString
    print("Value", value)
} catch { print(error) }
Note

SE-0047 (warn unused result by default) and SE-0049 (move autoclosure) both affect many of the snippets in this proposal

Disadvantages to this approach:

  • It consumes a new operator, which developers must be trained to use
  • Unlike many other operators and specifically ??, this cannot be chained. There's no equivalent to a ?? b ?? c ?? d or a ?? (b ?? (c ?? d)).

Alternatives Considered

Extending Optional

The MikeAsh approach extends Optional to add an orThrow(ErrorType) method

extension Optional {
    func orThrow(@autoclosure error: () -> ErrorType) throws -> Wrapped {
        guard let value = self else { throw error() }
        return value
    }
}

Usage looks like this:

do {
    let value = try Int("NotANumber")
        .orThrow(InitializerError.invalidString)
    print("Value", value)
} catch { print(error) }

An alternative version of this call looks like this: optionalValue.or(throw: error). I am not a fan of using a verb as a first statement label.

Disadvantages:

  • Wordier than the operator, verging on claustrophobic, even using Swift's newline dot continuation.
  • Reading the code can be confusing. This requires chaining rather than separating error throwing into a clear separate component.

Advantages:

  • No new operator, which maintains Swift operator parsimony and avoids the introduction and training issues associated with new operators.
  • Implicit Optional promotion cannot take place. You avoid mistaken usage like nonOptional ??? error and nonOptional ?? raise(error).
  • As a StdLib method, autocompletion support is baked in.

Introducing a StdLib implementation of raise(ErrorType)

Swift could introduce a raise(ErrorType) -> T global function:

func raise<T>(error: ErrorType) throws -> T { throw error }

do {
    let value = try Int("NotANumber") ?? raise(InitializerError.invalidString)
    print("Value", value)
} catch { print(error) }

This is less than ideal:

  • This approach is similar to using && as an if-true condition where an operator is abused for its side-effects.
  • It is wordier than the operator approach.
  • The error raising function promises to return a type but never will, which seems hackish.

Overriding ??

We also considered overriding ?? to accept an error as a RHS argument. This introduces a new way to interpret ?? as meaning, "throw this error instead of substituting this value".

func ??<T>(lhs: T?, @autoclosure error: () -> ErrorType) throws -> T {
    guard let value = lhs else { throw error() }
    return value
}

Usage:

let value = try Int("NotANumber") ?? Error(reason: "Invalid string passed to Integer initializer")

This approach overloads the semantics as well as the syntax of the coalescing operator. Instead of falling back to a RHS value, it raises the RHS error. The code remains simple and readable although the developer must take care to clarify through comments and naming which version of the operator is being used.

  • While using try in the ?? statement signals that a throwing call is in use, it is insufficient (especially when used in a throwing scope) to distinguish between the normal coalescing and new error-throwing behaviors.
  • Error types need not use the word "Error" in their construction or use. For example try value ?? e may not be immediately clear as an error-throwing intent.
  • Overloading ?? dilutes the impact and meaning of the original operator intent.

Future Directions

We briefly considered something along the lines of perl's die as an alternative to raise using fatalError.

Acknowledgements

Thanks Mike Ash, Jido, Dave Delong

@davedelong
Copy link

(spitballing)

What about just adding an override of ???

func ??(lhs: T?, error: ErrorType) throws -> T {
    guard let value = lhs else { throw error }
    return value
}

Then you'd just do:

let value = try Int("NotANumber") ?? Error(reason: "Invalid string passed to Integer initializer")

(I haven't actually tried this; I'm just not a big fan of introducing more operators all over the place, when I think the existing ones could be extended to do the same job)

I see the ?? operator as the "do this if you can, otherwise do the right-hand side". For nil coalescing, it's the "unwrap the left if you can, otherwise use the right". For this it'd be "unwrap the left if you can, otherwise throw the error on the right".

@pyrtsa
Copy link

pyrtsa commented Apr 6, 2016

Quick thoughts:

  1. I don't think we should invent a new operator for this purpose.
  2. @davedelong I don't think it's a good idea to add yet another overload to ??. That would at least make
  3. @jido I agree with you. And I gracefully disagree it being "claustrophobic."

On the contrary, I see many more upsides to Optional.orThrow:

  1. The name Optional.orThrow(_:) makes it clear what it means and does (vs. anything suggested above). Some alternative spelling could work even stronger, e.g. Optional.or(throw:_).

  2. It's not that common (in my subjective experience) where you need to convert an optional into throwing something. Spending one more precious permutation of ASCII punctuation characters for that purpose seems wasteful to me.

  3. There's no new standard operator to learn and understand (vs. introducing ???).

  4. It doesn't overload the lookup, usage, or meaning of any existing operator (vs. the trick with ??).

  5. Because it's a method of Optional rather than an operator, implicit Optional promotion cannot take place so it can't be mistakenly abused in cases like nonOptional ??? Error(...) (or nonOptional ?? raise(Error(...))).

  6. Because it's a method of Optional, auto-completion will bring it to your fingertips.

  7. Unlike ?? where it's moderately common to chain multiple Optional expressions (optionally followed by a non-Optional one), you would never chain ???. That means you gain very little from it being an operator:

    Remember that a ?? b ?? c ?? d means a ?? (b ?? (c ?? d)), so it also exists to save a bunch of parentheses (compare to a.orElse(b.orElse(c.orElse(d)))). With ???, we'd only save at most one pair of parens, and even then, I actually think it's clearer that the left-hand side of an expression like try (Int(string1) ?? Int(string2)).orThrow(...) has the Optional part of the full expression separated from the rest with parentheses.

@pyrtsa
Copy link

pyrtsa commented Apr 6, 2016

@erica Mike's code should actually come with @autoclosure because you don't want the error to be constructed in case it won't be thrown:

extension Optional {
    @warn_unused_result // (until SE-0047 has landed)
    func orThrow(@autoclosure error: () -> ErrorType) throws -> Wrapped { // N.B. SE-0049 moves `@autoclosure` to the right of `:`
        guard let value = self else { throw error() }
        return value
    }
}

@pyrtsa
Copy link

pyrtsa commented Apr 6, 2016

Also, maybe instead of

let value = try Int(input)
    .orThrow(Error(reason: "Invalid string passed to Integer initializer"))

you would actually use an enum case for this:

let value = try Int(input).orThrow(Error.BadInput(input))

and specify within your enum Error what .BadInput(input) means and possibly how its message should be formatted to the end user.

@erica
Copy link
Author

erica commented Apr 6, 2016

@pyrtsa,

Incorporated your comments. I moved the mention of SE-0047 and SE-0049 into a separate note. I replaced my stringity error with an enum. Posted to SE-list with warning that we disagree and this is a "pre-draft". Also I messed up the on-list guard statements but they're fixed now in the gist.

Also: Sean Heber makes a really excellent argument against doing anything:

Interesting, but I’m unsure if all of it is significantly better than just using the guard that is effectively inside of the operator/func that is being proposed:
guard let value = Int("NotANumber") else { throw InitializerError.invalidString }

@jido
Copy link

jido commented Apr 6, 2016

Perfect. Just take Sean Herbert's proposal and document it.

@JetForMe
Copy link

Not sure where this is these days, but I find myself wanting to write

let foo = myOptional ?? throw someError

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment