Typed throw functions

Take Result or SomePublisher as an example. Make their Failure type Never. What do you get from that? The answer is 'a strong compiler guarantee that there is no error that can happen and that the publisher never errors out'.

In general this allows us to write better generic code. Please review the linked thread for more information about other issues we‘d close with such design.

If you look at return (implicit or explicit):

// not a tuple here
(A, B)    === (A, B) -> Void

// never returns anything
(A, B) -> Never
3 Likes

But I guess I still disagree. Lifting something up into another type to make it composable should be done at the point you want to compose something, not before that? Take a Future. Why not making everything a Future then, so you have errors and async combined?

I will read the link. Thanks.

I‘m saying exactly the latter. Everything returns and everything throws. If you don‘t explicitly write throws the compiler will implicitly know that its throws Never. This is how implicit Void works in Swift, but we‘d have an implicit throws Never in all non-throwing places.

This is simple and the best design for generic code in my opinion. You can feel free to disagree, but that‘s the ultimate design I always wanted and not willing to support anything else unless it can be proved to be superior.

// it never throws nor returns anything
fatalError() -> Never === fatalError() throws Never -> Never

// never throws anything
foo() === foo() throws Never -> Void
func bar<E: Error>(_: () throws E -> Void) {
  print(E.self)
}

// okay
bar(foo) // prints Never
8 Likes

Let me try again. I don't think common supertype is any better a choice than just straight-up Error. You already lose the exhaustiveness, which is mostly the point of typed throws.

4 Likes

So I see this two approaches two stacking/combining effects:

  1. Additive approach: Define a structure/operations to combine effects abstractly (e.g. Monads and Monad Transformers in Haskell)
  2. Subtractive approach: Build a selection of concrete effects into types/functions (e.g. throws in Array functions in Swift) and disable them by adding special keywords like e.g. Never.

So as far as I can see 2. was selected for Swift. What is the reasoning which effect will make it into the language's types and functions? Is it decided case by case or is there a rule set (like every effect with an annotation like throws or later async)?

Well, there are really three broad ways you can integrate effects into a language:

  • monads
  • a generalized effects system where effects have the ability to trigger arbitrary code rewrites
  • direct but one-off support for specific effects

Monads are great for effects if you don't care about implications for either reading or writing code. They use a very straightforward model that you can easily hook into a language design and give some simple syntactic sugar to. If you do care about reading or writing code, they're terrible. Monads effectively create a second kind of function call that composes completely differently from ordinary expression evaluation, creating widespread spurious usability problems. If a refactor introduces a monadic effect to some function, the functions that call it generally to be almost totally restructured. Expressions that don't interact with the monad often have to be explicitly lifted into it, while expressions that do interact with it just quietly have the right type; this is exactly backwards from what you almost always want for readability purposes, which is that the parts of a function that have monadic effects are special and important and should be easily findable, while the parts that don't have effects don't need to be called out. Monads also compose with each other extremely poorly because the order of monad application is assumed to be significant, when the actually-desired language effect is usually something more like "this function has two effects".

Generalized effects systems solve some of these problems: it stops being necessary to explicitly lift code that doesn't have effects, and the unordered composition of effects works well from at least the type system perspective. Expression-rewriting can be very tricky, though, especially with composed effects; I know there's been very recent work on this, but it's an active research area. And the genericity of the system usually doesn't make it easy to require "marking" of effect-laden code, especially different marking for different effects, which is something we think is quite important for many effects.

Also, both of the effects systems that we've looked at seriously — throws and async — are things where we care a lot about the technical details of how they're compiled. Using a generic rewrite system would not lead to acceptable results.

15 Likes

Thanks a lot for this overview. I need some time to go through the arguments, but it gives me insights in why the team chose the current approach. :+1:

Being curious, is there some effect removing mechanism for async like there is for throws with Never?

Regarding this: (X, Y, Z) -> W === (X, Y, Z) throws Never -> W

how would that look for async then: (X, Y, Z) -> W === (X, Y, Z) not async -> W?

Like any other effect, you can imagine a “re-async” where the async-ness depends solely on the effects of something else, like a function that’s passed in. But no, I can’t think of any way that async would be parameterized such that you could use something like Never to disable it.

How about async Never == sync?

How about a possibility of constraining what execution context (e.g. a dispatch queue, an operation queue, or a thread) this function is designed to be executed on? This feature has been discussed in the forums and the go-to syntax was chosen to be:

on(.main) func doUIStuff() { /* ... */ }

If this syntax was replaced by a parametrized async it would make sense in terms of the effect of this constraint and also would enable the async Never case:

func doUIStuff() async(.main) { /* ... */ }
2 Likes

This is getting into the concurrency-design weeds a bit. Being able to constrain a function to run on a specific actor is certainly important sometimes, but only for synchronous functions. For asynchronous functions, it might be important that they actually run on a specific actor, but it's not important that they be called on that actor because the call can always switch to the right actor dynamically. At best, a type constraint would enable some relatively minor optimizations.

Regardless, the absence of an actor constraint wouldn't mean "this isn't async", it would mean "this doesn't care about what actor it's called on".

3 Likes

Counterpoint:

  • You don't know every every possibility for non-frozen enum
  • You do know every possibility for CaseIterable class
1 Like

Isn’t the compiler warning you of that sort of thing the point of having typed errors in the first place?

Library Evolution section of the pitch draft (link in first post) covers this case.

Just giving you an update. Because we want to integrate the feedback and additions you give us (thanks a lot for this!) we need time till end of the week for the draft. We want to include as much as possible, so the discussion on the draft can be more focussed. Thanks for your patience. :sweat_smile:

11 Likes

Just crashing on failure to save a file, without any feedback to the user, is not exactly the best behavior either. This is because an error that is unrecoverable to your method may still have generalized logic at a higher level. But for logic errors and the like, it is far better to fatalError so that you could capture the system state in the core dump.

When you have a generalized algorithm which is composed of different methods, there will be errors which it can understand and errors which it can't. But those errors could still be understood at a higher level, such as the code which composed the instance of the algorithm (supplying protocol instances, etc).

Think some of the challenges we had when trying to add a random number interface to Swift - when you plug in a random number source, it might be a seeded function, it might be an operating system call, it might be a block device, etc. These all can have different kinds of failures - some of which you can move to the act of composing (seeding with zero, unable to open /dev/random), some of which happen while requesting random numbers.

Errors are not recoverable to the random number algorithms, at best they can try to end up in a consistent state. But those errors might be recoverable by the code which composed that random number source into the algorithm. Having the random number algorithm attempt to map an unknown error into a RandomNumberOperationError.other(Error) (or worse, RandomNumberOperationError.unknownErrorHappened with no data) enumeration member is busy work on both sides - now rather than dealing with the error directly, the caller will have to know to look for and unwrap this error and then switch on it (inside the case) to see what actually went wrong.

map and filter are methods which do this correctly, just rethrowing the error they encounter while leaving the system in a consistent state (discarding the partially created result set). However, they do not generate their own Errors, so they make poor examples for this particular point.

Yes, my concern with error types as part of a method signature is mostly due to the lack of first class sum types, because then when your algorithm is composed with other code which throws unexpected errors, you either have to discard error typing, discard the error information, or wrap errors in new types which will make handling in calling code being some mix of more complex, slower, and more error prone.

I will of course admit there are cases where you do still wrap errors to attach additional information (such as recovery information to continue the operation once the error is resolved) - I'm more concerned about the case where typed errors complicate existing error handling without providing any additional information.

Documentation and linter-style functionality on the other hand can make up for the lack of sum types by documenting the different kinds of errors which can be thrown, as well as what subset of cases within an Error enum are thrown by a particular method. This closer to the approach Typescript takes, as the underlying language implementation (Ecmascript) has no clue what the typescript transpiler knew or warned the developer about.

I don't know if it was already mentioned that a non-throwing function is a subtype of a throwing function with the same signature. This allows non-throwing protocols to inherit from throwing protocols:

protocol Throwing {
    func f() throws
}

protocol NotThrowing: Throwing {
    // A non-throwing refinement of the method
    // declared by the parent protocol.
    func f()
}

struct S: NotThrowing {
    func f() { print("S") }
}

// Compiles, and prints "S" thrice
S().f()
(S() as NotThrowing).f()
try (S() as Throwing).f()

Based from this example, I suggest "typed throw" allows the definition of more complex hierarchy of intermediate protocols. Maybe something as below:

protocol ColoredError: Error { }
class BlueError: ColoredError { }
class DeepBlueError: BlueError { }

protocol ThrowingColoredError: Throwing {
    // Refinement
    func f() throws ColoredError
}

protocol ThrowingBlueError: ThrowingColoredError {
    // Refinement
    func f() throws BlueError
}

protocol ThrowingDeepBlueErrorError: ThrowingBlueError {
    // Refinement
    func f() throws DeepBlueError
}

I'm not 100% sure about the above code, because it contains both protocol and subclass inheritance. And unlike Error, the ColoredError existential does not conform to the ColoredError protocol. So I may well miss something. Experts of Swift subtyping rules know better.

Yet I just wanted to stress the importance of subtyping in chains of inherited protocols, and the preservation of a high level of language consistency that maximally extends existing language features. Blind spots are hard to fix later.

2 Likes

What do you mean by first-class sum types? What features are current Swift enum types missing?

You cannot return a type like String | Int, instead you have to declare a tagged union (enum) which requires the developer to set and understand semantics on wrapping/unwrapping the value into/out of the enum.

so while I might want to describe that a method can throw FrameworkSpecificError or NetworkError, instead I need to either throw a common super type like Error or have a specific semantic for the call that manually needs to be dealt with, like matching against FrameworkSpecificError.networkError(...) rather than against NetworkError.

However, this distinction is disguised by any intermediary code. If the method which calls this framework just re-throws the error and declares the type as Error, code which attempts to catch NetworkError will not be able to match the proper type. This may mean detecting any number of specific errors which are wrapping errors, including multiple levels of wrapping which are disguised as just the Error marker protocol.

My concern about typed throws is that in combination with enumerations rather than real sum types you risk an increase in code and complexity and a loss of robustness on both sides (method implementor and user) to get the same functionality you would today with just throwing different kinds of Error.

With Java, my experience has been that an increase in implementation complexity results in developers spending less time evaluating their actual goals with error reporting and handling, to the point where errors only serve a post-mortem diagnostic purpose - but Swift does not capture enough execution state (such as the stack trace) to provide equivalent value there when compared to Exception in other languages.

See this proposal: https://github.com/frogcjn/swift-evolution/blob/master/proposals/xxxx-union-type.md