Type-safe and user-friendly error handling in Swift 6

(theswiftdev.com)

54 points | by TheWiggles 6 days ago ago

29 comments

  • dcrazy 3 days ago

    Some people might be surprised that statically typed errors have arrived so late in Swift’s life. This is because typed errors are a bad default. Without deep consideration, it is easy to design an error type that encodes too much implementation detail into the type system. Then if your implementation grows new failure modes, you either have to break your ABI (e.g. by adding a new case to your error enum) or lie about the nature of the error, calling into question the value of encoding errors in the type signature at all. And at a certain level of abstraction, it’s just impossible to predict all the failures that can occur. We learned these lessons from Java’s checked exceptions experiment.

    • lmm 2 days ago

      Nah. Java checked exceptions failed because they weren't values and were impractical to use in generic code. The lesson isn't that you shouldn't put errors in your type system, it's that you should put them fully in your type system and make them as easy to use as regular types.

      • vips7L 2 days ago

        They also failed because Java never made them easy to work with. If you faced a checked error that you couldn't handle it is impractical to uncheck them. You have to write a shit ton of boilerplate.

            A a;
            try {
                a = someThrowingFn();
            } catch (AException ex) {
                throw new RuntimeException(ex);
            }
        
        It sucks. In Swift this is simply: val a = try! someThrowingFn(); Kotlin is moving towards having both unchecked exceptions (panics) and errors as values. I think its absolutely the correct decision. They're also including an easy way to panic when you can't handle an error: val a = someThrowingFn()!!;

        The other thing that I think made checked exceptions worse in the beginning was the lack of pattern matching and sealing with exceptions. Being able to exhaust all the different types of a particular error is really important if you want to properly respond. If you had a meaningless top level exception like IOException you couldn't effectively determine what actually happened and everyone just defaulted to unchecking it. Nowadays it's a little better if you seal your exception hierarchy:

            sealed abstract class SomeException extends Exception permits A, B {
                final class B extends SomeException {}
                final class C extends SomeException {}
            }
            
            try {
                someThrowingFn();
            } catch (SomeException ex) {
                switch (ex) {
                   case A a -> iCanHandleA(a);
                   case B b -> throw new IllegalStateException(b); // can't handle this one
                }
            }
        
        Hopefully that becomes even better with the exceptions in switch [1] proposal:

            switch (someThrowingFn()) {
                case throws A a -> ..
                case throws B b -> ..
            }
        
        
        The real big issue like you said is that they're not completely in the type system. Especially when it comes to lambdas; you need to invent a Result<T, E> type every time you want to work with lambdas. Obviously all of these things are solvable with library code, try! is easily some static panic function; it sure would be nice to not have to write them. Overall I'm hopeful that with the overall programming community moving back towards checked errors that the OpenJdk team will invest in making checked exceptions better, but only time will tell.

        [0] github.com/Kotlin/KEEP/blob/main/proposals/KEEP-0441-rich-errors-motivation.md [1] https://openjdk.org/jeps/8323658

      • pjmlp 2 days ago

        Yet I regularly miss them when coding in C# and Typescript.

        I also used the C++ exception specifiers, which people keep forgetting is where Java's idea came from, alongside other languages like Modula-3.

        • lmm 2 days ago

          > I also used the C++ exception specifiers

          The legendary only feature so bad it was actually removed from C++? Those exception specifiers?

          • pjmlp 2 days ago

            You forget that many C++ developers would also remove exceptions and RTTI if they gathered enough votes.

            If that is the point your trying to make.

            I on the contrary, would rather use the full language without dialects.

            And the feature worked, unlike export templates or C++ GC, also removed.

    • ChrisMarshallNY 3 days ago

      I’ve learned to keep errors simple. I have found that the most important thing is to anticipate and trap the error. Reporting it is actually the simplest part of the thing.

      The kind of structure mentioned in the article, with things like contexts, and calling chains, is stuff that I did for years.

      In my experience, it had a glass jaw. It broke easily. The more complicated something is, the more places it has to break.

      Also, I found that I never, ever used it. The most introspection I ever did, was look at an error code. Where the error happened, was the important thing, and a symbolic debugger gave me that.

      These days, I just declare an Error-conformant enum, sometimes with associated data, and add a few computed properties or functions.

      It’s most important that I write my code to anticipate and catch errors. The report is always refined to be as simple and gentle to the user, as possible. The technical stuff, I have found, is best handled with a symbolic debugger.

      But that’s just me, and I know that the type of software that I write, is best served with that model. YMMV.

    • brundolf 2 days ago

      I like Rust's approach, where common "errors" (eg unexpected responses from other systems) have to be handled, while true unrecoverable errors don't affect the signature

      • jlokier 2 days ago

        That's actually the same as Java, with its "checked exceptions" (aka listed in function signatures), and the RunTimeException hierachy which don't have to be listed.

        In Java common errors and exceptions, like file I/O errors, have to be declared on each function signature, except everything under the RunTimeException hierachy is exempt from this requirment. In the language, RunTimeExceptions these are the messy errors like NullPointerException and ArithmeticException.

        In practice, people do subclass program-specific exceptions under RunTimeException in Java, as well as wrapping existing exceptions inside a RunTimeException, for the sole purpose of not having to add them to function signatures all over the place.

        • Ygg2 2 days ago

          > That's actually the same as Java, with its "checked exceptions" (aka listed in function signatures), and the RunTimeException hierachy which don't have to be listed.

          You are correct, but the logic is inverted. Most anticipated errors in Rusts are handled by Result. Errors that aren't anticipated i.e. panics, crash the thread, and if it's the main thread the program.

          In Java terms, Rust has return value as error, which can be thought as checked exceptions; and Error class in Java that just kills your program.

          Stuff isn't as clean cut ofc because Rust has allowed panics to be caught by main thread.

        • skitter 2 days ago

          > for the sole purpose of not having to add them to function signatures all over the place.

          I thought it was because you couldn't be fully generic over exceptions.

    • aatd86 2 days ago

      The problem doesn't seem to be typed errors the way you describe it, but using an enum or a finite union. Go has solved this by having the error interface as the default common error supertype across the type system and ecosystem for instance. That doesn't preclude having finite subsets where it matters. Now if for some reason you have to add new failure modes, probably that the library should bump its version.

      Java's checked exception issue is probably another problem altogether. I'd tend to say control flow issue perhaps?

    • rTX5CMRXIfFG 3 days ago

      Agree with the point on statically typed errors. However, hasn’t it been possible to define a custom Error type since Swift 1? Or did you mean the typed throws?

      Have also been picking up Java and the thing that comes to my mind is sealed classes, and whether they are similarly bad design.

      • MBCook 2 days ago

        It’s typed throws specifically.

        Before Swift 6 a function either could throw nothing or could throw anything, like Java’s “throws Exception”.

        Now you can mark that a function can only throw a specific error type but nothing else, like Java’s “throws MyCustomException”.

        Seems you can’t list multiple types. You’d have to have them in a class hierarchy and mark the base type.

        What I like about this idea: if a function is marked like this, Swift can tell that a catch block is exhaustive without needing a “catch all” error case. And if the function starts throwing more possibilities than you coded for, e.g. new case added to an enum, you get a compiler error! Not a submarine bug.

        • dcrazy 2 days ago

          That last bit is great for application authors but awful for API designers. :)

          • conradev 2 days ago

            The best API design is likely to erase the public type to the `any Error` existential …

            … but Swift has also the tooling to declare ABI-stable enums without freezing them. Clients are forced to handle unknown cases.

          • MBCook 2 days ago

            Yeah it could be risky. Might be a feature best used inside the library implementation but not exposed in the public interfaces.

            I’m not sure how this would interact with the API compatibility Swift has. Maybe that would help authors.

        • jayd16 2 days ago

          Why do you need to restrict to a single base type to exhaustively catch all listed?

          • MBCook 2 days ago

            From my reading you can’t say “throws(MyError, BadError, ThirdError)”. So you only have two options. You could use a non-checked throws like before, but we’re assuming you want it checked.

            Since you can only put one name between those parenthesis, the only way to do all three would be to have a base class and list that. Or at least that’s my understanding.

            However if you do that and there’s a fourth type called UnwantedError, that’s also valid to throw even though you don’t want to.

            It’s an interesting limit. I didn’t follow this feature, though I remember hearing about it, so I don’t know why that choose was made.

            • Someone 2 days ago

              > the only way to do all three would be to have a base class and list that

              I think the Swift way would be

                  enum FooError: Error {
                      case myError(MyError)
                      case badError(BadError)
                      case thirdError(ThirdError)
                  }
              
              and declare your function as throws(FooError). That forces handlers of those exceptions to consider all cases.

              Edit: I think there’s a case for having the compiler synthesize that if one writes something like throws(MyError||BadError||ThirdError), but I’m not sure I’d would like to have such magic. Would two functions with such a declaration throw the same type of exception? That might matter if you do stats on exceptions logged, for example)

              • MBCook 2 days ago

                It does work that way, but I was thinking of the case where FooError was one of the three types you wanted to throw.

                You can also declare errors as structs, so that’s where you could use inheritance.

      • dcrazy 2 days ago

        The new feature here is typed throws, which I referred to as statically typed errors. You’ve always been able to throw a subtype of Error and then dynamically downcast it at runtime, but function signatures were limited to “throws any subtype of Error” or “doesn’t throw at all.”

        Sealed classes fall into the realm of things that the Swift language designers would leave up to convention. For example, using underscored public methods for internal methods that need to have external linkage, rather than inventing a new keyword for this purpose.

      • vips7L 2 days ago

        Why would sealed classes be bad design? It helps the compiler and you ensure that you have correct code and have handled all cases.

  • dep_b 2 days ago

    I use them a lot, for example in situations where I want to test why my code failed at a pre-check, so I can Unit Test its behavior better. Imagine a function that early returns for a variety of different reasons, and you want to test them to fail all.

    When you have a typed throw that throws different errors for every edge case failing, even when an edge case failing is part of the expected behavior of the system, makes it much easier to reason about. Even when there is nothing happening towards the user or being logged.

    Example:

    Restore a user's session. It can fail because:

    - Nothing was stored

    - Something was stored, but it was in an outdated format

    - Something was stored, but the token is expired

    - Etcetera

    It doesn't matter to the user, it's expected behavior, but at the same time they're different branches that all need to be covered.

  • jayrhynas 2 days ago

    I don't really see what advantage typed throws are giving him in this article, since he just wraps arbitrary errors and then uses his lookup function - isn't that basically the same as `catch let error as DecodingError`?

  • bze12 2 days ago

    Why do we still have to write `catch let error as SystemError`? Why can't the error be inferred to have the type thrown by the function? I've always found swift's error handling syntax to be awkward

    • dep_b 2 days ago

      But that's exactly what typed throws do?

      `static func validate(name: String) throws(ValidationError)`

      Would be handled as:

      ```

      do {

          try UsernameValidator.validate(name: name)
      
      } catch {

          switch error {
      
          case .emptyName:
      
              print("You've submitted an empty name!")
      
          case .nameTooShort(let nameLength):
      
              print("The submitted name is too short!")
      
          }
      
      }

      ```

      See: https://www.avanderlee.com/swift/typed-throws/

      • bze12 a day ago

        Oh I see, the original article didn’t use this syntax.

  • nielsbot 2 days ago

    Going to say my crazy thing that I like to do:

        extension String: Error { } 
    
    Because I like doing this:

        throw “Something went wrong” 
    
    Admittedly I don’t use this in shipping software.

    As for the feature: My “hot take” is: I’m skeptical this will really make software better and I suspect instead programmers will just spend more time designing error types and error handling than just creating software.