[Pitch] Two changes to `throw` making Sequence operations more powerful

I’d like get some feedback on the implications of making two small error-related changes which could work really well together:

  1. Allow throw as an expression, i.e. return someOptional ?? throw error.
  2. Allow throw without argument, throwing an inbuilt empty error type (an ‘informationless’ error).

We already have a natural way to convert errors to optionals using try?, but converting optionals to errors is a bit clumsy – it doesn’t work inline and (if you don’t care about the error) requires picking an arbitrary error.

guard let value = foo() else { throw SomeArbitraryError() }
bar(value)

// Versus:
bar(foo() ?? throw)

This in itself is a convenient albeit merely syntactical change. However for many fundamental operations this actually enables new use cases (by making it practical to do so).

In particular, sequence operations would automatically have support for cancellation, following naturally from the language:

// Return the mapped array, or nil if any of the transformations fail.
let parsedArray = try? [ "5", "17", "foo" ].map { Int($0) ?? throw } // All or nothing parsing

// Cancellable enumeration/search (equivalent to previous 'stop' parameters/breaking)
let x = try? largeSortedArray.contains { $0 <= max ? $0 >= min : throw } ?? false

Summary

  • Natural extension to the language
  • Completes Optional <=> Error conversion syntax
  • Broadly useful as general stop/break in closure sequence operations
5 Likes

+1

I’m in favor of #1. I think it could eliminate a lot of unnecessarily wordy guard/else constructs. (I similarly think someOptional ?? preconditionFailure(…) should work, but that’s a different story.)

I’m not so much in favor of #2. Suppose one of these anonymous errors escapes because you wrote try instead of try?; how will outer layers of code see them? What will appear if you call localizedDescription on one in top-level error message display code?

I might actually prefer an allMap method or something which would terminate early and return nil if its transform returned nil. That at least uses entirely local effects instead of abusing throwing.

1 Like

throw would be short for somehing like throw GenericError()

Where the standard library would include

struct GenericError : Error { }

Exact implementation (enum, struct or otherwise) would be up for discussion. But effectively an empty type conforming to Error.

I currently always use custom functions like you describe btw (failableMap, failableFlatMap, etc.)

While I agree with the premise of 1., it is easily solved with a simple extension on Optional, so I don’t think a language change is warranted here.

Furthermore, this change would allow nonsensical code like 42 + throw SomeError() to be valid Swift, which doesn’t sound good to me.

Here is the extension I tend to use:

extension Optional {
    func unwrap(or error: @autoclosure () -> Error) throws -> Wrapped {
        switch self {
        case .some(let w): return w
        case .none: throw error()
        }
    }
}

For 2., I fear that this, being the easiest way to throw an error, would discourage developers from defining proper errors types. Secondly, defining an error type is very easy thanks to enums, so I don’t think this would benefit the error handling story.

1 Like

While the example is more elegant, one should be clear that you can do the same thing now. It requires a little bit more in terms of characters to type, but we aren’t enabling some new use case.

I’m not sure what your cancellable contains example means. It seems never to proceed past the first element, so alternative ways of spelling that are likely to be more clear.

Overall, this seems like it’s a potentially interesting idea to explore. However, as stated in the Swift Evolution Readme, syntax changes have to clear a very high bar, and I think it remains to be seen how much power we truly gain here and I’d be interested to see how the conversation unfolds.

I’m not sure about cancellable mapping, because it might interfere with cases when the closure throws for a more serious reason that shouldn’t be treated as a break. My suggestion would be to have a special error value that would serve the exact same purpose as Python’s StopInteration exception. The break keyword could be reused as a syntax sugar for throw Swift.stopIterationError because it usually does exactly the same thing (exit a repeating scope abnormally).

The idea of having a default meaningless error is fantastic in my opinion. It makes perfect sense when you try to deconstruct the idea of an optional (which is “a value or an information-devoid error”). It also has huge productivity boosting potential for prototyping, where an optional is clearly not enough, so you want to throw an error, but you haven’t designed the error type yet and would rather focus on more important issues at hand while leaving room for easy substitution of blank errors with meaningful ones if and when it becomes necessary.

I don’t think things like 42 + throw SomeError() would be an issue, because throw SomeError() is statically guaranteed to always throw, so the compiler can generate a warning much like the one generated when you try to conditionally upcast a derived type to a base type (because it’s statically guaranteed to always succeed).

Regarding discouragement, most errors are currently not particularly informative, because they are mostly simple symbolic enum cases, so even if the coder comes up with a couple of cases, they typically don’t include contextual information and just stick to throwing a cookie-cutter enum case error. I think the existence of a default error will serve as both a very easy way to get things done while leaving room for improvement (unlike optionals, which would require the coder to redesign the interface and the underlying implementation to introduce meaningful errors), while encouraging the coder to put more thought when designing the errors when they decide that it’s time to do so.

Technically yes, this is already possible – but practically speaking I don't think many people use it in it's current form (a bit clumsy, and requires an arbitrary error):

let parsedArray = try? [ "5", "17", "foo" ].map { string in
    guard let value = Int(string) else { throw SomeArbitraryError() }
    return value
}

I personally use custom functions (failableMap etc.) which, while frequently used, I wouldn't suggest adding to the standard library considering it individually duplicates all the sequence methods.

It checks whether the sequence contains an element in min...max but (since this is a large sorted array) stops iteration once the element has become larger than max (since then we can already be sure there is no such element).

Since that unconditionally throws, and therefore never performs subsequent operations, this would most likely result in a compiler warning. Also note that 42 + (try calculation()) is already valid Swift.

This does loose you the parameterless throws, and similar to the form above, it's clunky enough that I think most people would not use it for this case (failableMap):

let parsedArray = try? [ "5", "17", "foo" ].map { try Int($0).unwrap(or: SomeArbitraryError()) }

Just to clarify, the proposal would in now way change the sequence operations like map itself – my proposed usage is purely in the hands of the caller. I'd suggest to keep throws truly informationless such that it remains broadly applicable. In the (pretty rare) case you'd use a similar pattern w a body that also throws errors, there is information in the error (since there are multiple) so you'd either throw a specific custom error or use another pattern altogether.

You’re making two separate proposals here I think.

  1. About throw as an expression, I think it makes sense. I observe that we can already do that today using a closure:

    try 42 + { throw MyError.someCase }()
    

    So it already works, sort of. Removing the requirement of wrapping it in a closure would be a small improvement. Maybe it’s worth it.

  2. About that informationless throw, I think the intent isn’t clear. If you want to cancel whatever you are doing, you ought to be explicit about it. Having a simple error type dedicated to cancellations improves the readability of the code in my opinion:

    /// Used to cancel chaining operations that can't continue due to an error.
    /// Local use only.
    struct Cancel: Error {}
    
    let result = try? 42 + { throw Cancel() }()
    

    Having a “whatever” error type with no real meaning is fine in some cases, but I don’t think we should encourage it to the point of making it the default and the easiest to write.

Wow, the closure notation is Really Nice™! With that technique available, I’m really not sure that a specific feature for the purpose of omitting {...}() is doing much.

Agree also on your thoughts about the second part of the proposal.

If we had the Never type set up as a bottom type, this could generalise to statements like let a = b ?? fatalError(), as statements like throw and return could then become Never-returning expressions (unifying the concepts of code-after-return/throw and code-after-never). I believe having it be a bottom type (castable to any type) was mentioned in the proposal for Never.

Rather than special-casing throw temporarily, I’d prefer to see this as part of finalizing Never.

Edit: my thoughts didn’t fit into the definitions of the words I was using, but I do not know how to state what I meant validly, so I am leaving it here.
I don’t think Never should be a bottom type,
I think if swift gets a bottom type it should be a protocol like Any, perhaps called None, with a compiler enforced requirement of non-initializability (even private inits).

However, I do like return and throw be expressions that evaluate to Never.

I do believe that Never being the standard name of the bottom type is a settled issue.

1 Like

While nice from a theory standpoint, any other bottom type would be identical to Never in practice, and having multiple names for the concept is redundant and would only cause confusion in practice.

I don’t think so, because I imagine e.g. fatalError() could return a Crash and exit() could return a HaltedProgram. These uninhabitable types may be practically identical but semantically they provide a reason why the program will not return to the caller.

By definition, an uninhabitable type cannot be instantiated: you can’t return a Crash or a HaltedProgram because there are no such values. And every bottom type is semantically identical; if two types are distinguishable from each other, then one or both is not a bottom type.

You can make your own “unwrap or throw” overload of ??:

extension Optional {
    static func ??(lhs: Optional<Wrapped>, rhs: @autoclosure () -> Error) throws -> Wrapped {
        switch lhs {
        case .some(let w): return w
        case .none: throw rhs()
        }
    }
}

And use it like this:

struct Cancel: Error { }

let a: Int? = 5
let b: Int? = nil

let a1 = try a ?? Cancel() // a1 will be 5
print(a1)

let b1 = try b ?? Cancel() // this will throw
print(b1)

We lose the explicit throw at the consumer side but we have a try as a clue. You could also use a completely different operator to avoid confusion.

Of course this doesn’t cover all of the cases, but combined with closure syntax mentioned by @michelf, it does cover a lot of them.

1 Like

I am not referring to runtime semantics, where multiple uninhabited types are indeed identical, but rather to compile-time / type checker semantics, as in “what result can I, as a programmer, expect from this function being called”.

I'm not sure what you mean by different flavors of semantics, but by construction every bottom type has to be identical. If A and B are both bottom types, then A must be identical to B to you, me, and the type checker. This is because A cannot be a subtype of B if B is a subtype of A, and vice versa. Therefore, if A is not B, then one of these is not a bottom type.

1 Like