1. 7
  1.  

    1. 9

      It seems we are roughly converging on the error handling. Midori, Go, Rust, Swift, Zig follow similarlish design, which isn’t quite checked exceptions, but is surprisingly close.

      • there’s a way to mark functions which can fail. Often, this is a property of the return type, rather than a property of the function (Result in Rust, error pair in Go, ! types in Zig, and bare throws decl in Midori and Swift)
      • we’ve cranked-up annotation burden and we mark not only throwing function declarations, but call-sites as well (try in Midori, Swift, Zig, ? in Rust, if err != nil in Go).
      • Default is existentially-typed AnyError (error in Go, Error in Swift, anyhow in Rust, anyerror in Zig). In general, the value is shifted to distinguishing between zero and one error, rather than exhaustively specifying the set of errors.
      1. 13

        I would not put Go’s “error handling” in the same category as either checked exceptions or things like Rust.

        Both checked exceptions, and Rust’s fallibility-encoded-in-types, provide the ability for a compiler to force you to acknowledge a potential error at the call site where it might occur. Go’s approach is nowhere close to that – as far as I’m aware it happily lets you just not write an error-handling block and potentially bubble the error up, unhandled and without any notice to the caller.

        So, honestly, I prefer unchecked exceptions to Go – at least there if you don’t catch, you get a crash that tells you why you crashed and information to let you track it down.

        1. 3

          Aye Go’s is woefully insufficient out of the box, but there are linters which force you to ack errors (errcheck?). Past that I’m not sure any language can force you to properly handle error, Rust certainly does not e.g.

          _ = function_which_returns_a_result()
          

          Will pass muster in the default configuration (clippy may have a lint for that, I’ve not checked).

          The problem is really the go community which is woefully inconsistent and will simultaneously balk at the statement that OOTB Go is very much lacking, and tell you that the compiler being half-assed is not an issue because you should have golanglint-ci or whatever.

          1. 4

            It also has the problem that you can get both a return value and an error. While it is generally not advised to return a meaningful return value in the error case, it is not without example.

            It should be a proper sum type, either a return value, or an error. Exceptions (both unchecked and checked) fulfill this property.

          2. 2

            Past that I’m not sure any language can force you to properly handle error, Rust certainly does not e.g.

            The thing with Rust is that the compiler knows that a Result<SomeType, &str> isn’t a SomeType .

            1. 3

              That’s awkward to say aloud. :-) (I agree, though.)

              (Edit for non-native English users: Result<SomeType, &str> isn’t a SomeType, but it is a sum type, and “some” and “sum” have the same pronunciation.)

          3. 2

            The linters help some, but there are edge cases that they miss (or at least used to). The last time I was writing Go in anger, variations on this bug came up over and over:

            foo, err := f()
            if err != nil {
              return err
            }
            
            bar, err := g(foo)
            bar.Whoops()
            

            (I may be getting the exact details wrong, but the weird/subtle semantics around := with a pre-existing err variable caused pain in a number of dimensions.)

            1. 2

              Yeah the compiler only catches dead variables, here err is reassigned to do there’s no dead variables just a dead store, and the compiler will not say anything.

              Staticcheck should catch it, from what I’ve been told.

        2. 3

          What Go and Rust both have is explicit control flow around errors (though you have to get used to looking for those ?s in rust). That is to say, you aren’t longjmping half way across the project because of an error state.

          That makes things so much easier to reason about than what I’ve seen in Java/C++/JS (the latter two having the distinction of being able to throw things that aren’t even error objects).

          Call me lucky, but I’ve never had things go wrong because an error was actually unhandled.

          Things going wrong because they were in a different state than I thought? That one crops up quite often, and is worse with implicit control flow.

        3. 2

          Til, thanks! I was sure that go vet lints unused results, just like Rust does, but that doesn’t seem to be the case. The following program passes vet

          package main
          
          import (
          	"errors"
          )
          
          func e() error {
          	return errors.New("")
          }
          
          func main() {
          	e()
          }
          
          1. 2

            go vet does very little, for this you need errcheck, probably.

            Likely also staticcheck for e.g. dead store, as goc only checks dead variables.

          2. 1

            That’s not an error, but this is because err is unused:

            func main() {
            	e()
            }
            

            It does happen sometimes that you move a code block and an err ends up without an if err != nil after it, and it doesn’t trigger the “no unused values” rule, but it’s sort of unusual.

      2. 2

        This is exactly what I was thinking as I read the interview, so I found it really interesting that Anders Hejlsberg was so opposed to this style of error handling. He’s not someone whose opinion I’d dismiss easily!

        When Hejlsberg argued against checked exceptions on the grounds that adding a new type of exception to a function broke the interface, I completely agreed with the interviewer:

        But aren’t you breaking their code in that case anyway, even in a language without checked exceptions? If the new version of foo is going to throw a new exception that clients should think about handling, isn’t their code broken just by the fact that they didn’t expect that exception when they wrote the code?

        But Hejlsberg disgrees:

        No, because in a lot of cases, people don’t care. They’re not going to handle any of these exceptions. There’s a bottom level exception handler around their message loop. That handler is just going to bring up a dialog that says what went wrong and continue.

        Surely in any kind of event-driven application like any kind of modern UI, you typically put an exception handler around your main message pump, and you just handle exceptions as they fall out that way.

        You don’t want a program where in 100 different places you handle exceptions and pop up error dialogs. What if you want to change the way you put up that dialog box? That’s just terrible. The exception handling should be centralized, and you should just protect yourself as the exceptions propagate out to the handler.

        That’s when I realized that error handling in the software he’s talking about - GUI desktop applications - might be very different from the software that I’m used to writing. The scalability argument at the end also kind of rang true.

        I wonder if the Rust/Zig/Go style of error handling actually works well for GUI desktop applications. Do you have experience of that sort of thing?

        Is it your final point - that we default to existentially-typed AnyError - that prevents the problems Hejlsberg describes?

        1. 4

          If the new version of foo is going to throw a new exception that clients should think about handling, isn’t their code broken just by the fact that they didn’t expect that exception when they wrote the code?

          Note that this is a litmus test for the difference between checked exceptions and the approach taken by newer languages.

          In Java, throwing a new kind of thing requires updating both the functions that just propagate the errors, and the functions that handles the errors.

          With newer languages, you don’t have to change the propagation path. If a new variant is added to a Rust error enum, only the code that match needs to change, the ? paths stay the same.

          Separately, it turns out that pretty often you don’t want to update the match site as well (eg, because you don’t actually match exhaustively, so paying for allowing exhaustive match is worthless). The single Exception type allows for this.

          might be very different from the software that I’m used to writing

          I would say that that strategy applies to a vast majority of apps, which fall into one of the two categories:

          • there’s some sort of top-level event loop, and the underlying transactional data-store, where, if a single event loop turn goes wrong, you just alert the operator and move on (sometimes there’s an outer service manager loop which restarts your service on a crash)
          • it’s a “run-to-completion” program, like a CLI, where you print the error and exit (maybe not even unwinding the stack)

          Some notable exceptions here are:

          • localized retries, where you exponentially backof a network request, or do things like “if file does not exist, create file”
          • something high-reliability/embedded, where you precisely know every syscall you make and trace each and every error path

          Not an exception, but a situation which can often be misunderstood as one, is when your target domain has a notion of error. Eg, if you are writing a compiler, syntax errors in the target language are not host-language errors! A missing semicolon in the file you are compiling, and inability to read the file due to permissions issues are two different, orthogonal kinds of things. This also comes up when implementing databases: “transaction fails due to would be constraint violation” is not an exception, it’s a normal value of database’s domain model.

          1. 1

            In Java, throwing a new kind of thing requires updating both the functions that just propagate the errors, and the functions that handles the errors. With newer languages, you don’t have to change the propagation path

            That’s not true. If your n-level deep nested method had a return type of String, and you change it to Result<String, SomeErr>, then in all those languages you will need to refactor recursively until you hit the part where you want to handle the error condition. If it already returned a Result type Result<T, SomeEnum> as in your example, then analogously you can also just add more variants to it in Java with subclassing a checked exceptions.

            Currently no mainstream language is polymorphic this way — koka and similar experimental languages can do this by having first class Effect types.

            1. 2

              When I say a new variant, I mean specifically going from n, n > 0 to n+1, not about 0 to 1. If we restrict throws declaration to just throws Exception we get Swift/Midori/Go semantics, yes.

              If it already returned a Result type Result<T, SomeEnum> as in your example, then analogously you can also just add more variants to it in Java with subclassing a checked exceptions.

              This doesn’t work quite analogously. With enums, you can combine unrelated ErrorA and ErrorB into ErrorAorB. With sub typing and single inheritance, you either need to go all the way up to throws Exception, use composition (and break subtyping-based catch), or, well, throw ErrorA, ErorrB.

              1. 1

                Correct me if I’m wrong but Rust’s enums are regular old sum types like you would have in Haskell. Then you can’t have

                struct ErrorC {}
                
                enum MyErr {
                  ErrorA, ErrorB
                + ErrorC
                }
                

                Your ErrorC would need to wrap the outside defined ErrorC, so ErrorC(ErrorC). Composition works in a completely analogous fashion with subtyping as well. (The real difference between sum types and inheritance from this aspect is their boundedness/sealedness. That’s why Java’s sum types are named that way.)

                What would actually do what you want, you would need union types, that are similar in concept, but not completely the same. Typescript and Scala 3 do have these for example, denoted as A | B

                1. 1

                  Composition works the same, but catching breaks. Java catching is based on subtyping, and doesn’t work with composition. Rust catching is pattern matching, it works with composition.

                  1. 1

                    Java’s checked exceptions surely leave much to be desired, especially in ergonomics, but that can also be reasonably emulated by throwing/catching GenericException with a single composited object (perhaps of YourSealedInterface type since a few years), on which you can do pattern matching with a switch expression. Not beautiful by any means, but not weaker in expressivity.

                    As I wrote, I believe you have to go to Effect types to actually raise that level (so that for example a map function can throw a checked exception if its lambda does)

          2. 1

            With newer languages, you don’t have to change the propagation path. If a new variant is added to a Rust error enum, only the code that match needs to change, the ? paths stay the same.

            Ah, OK. That seems a lot like unchecked exceptions. I don’t have much experience with Rust - and I last tried it years ago - but I remembered error handling being fiddlier than that.

            IIRC, the annoying part was when I was writing a function for a library - i.e. to be used by others - but in turn I was using multiple other libraries, each of which returned their own error types. I remember writing a lot of boilerplate to create my own error type that wrapped all of the possible underlying errors. Is it possible to pass them along up the stack without doing that?

            1. 1

              This already is less work than in the world of checked exceptions. If an underlying library foo adds a new error variant to its foo::Error type, then your code already doesn’t need to change. The work is proportional to the number of error types, not to the number of the call-sites to failabel functions.

              It’s still a lot of work though! That’s why usually languages try to bless some sort of “any-error” type, like Swift or Go. It is quite a bit simpler if you don’t try to be precise with the types of errors, and just downcast at the catch site, if you need that. In Rust, this is available via `anyhow crate.

              One thing in this space I’ve realized recently is that anyhow’s approach is usually recommended for applications, but, because in the apps you don’t actually care about semver, just making a giant error enum with thiserror might also be fine, and even better along some dimensions.

              1. 1

                I just checked and the first commits to both the anyhow and thiserror repositories came about a fortnight after I last tried writing anything in Rust: they look like they would have helped with the boilerplate tedium.

                I really dislike exceptions because they obscure control flow: it makes code review very difficult. Most of my career has tended towards what you called “something high-reliability/embedded”, so I like to know exactly what errors can occur at any point in the program.

                It’s always interesting to learn about other domains and how practices, such as error handling, have different costs and benefits.

                1. 2

                  I just checked and the first commits to both the anyhow and thiserror repositories came about a fortnight after I last tried writing anything in Rust: they look like they would have helped with the boilerplate tedium.

                  anyhow and thiserror are two surviving siblings in a family tree of boilerplate-reducing Rust error libraries. Before they were released, there was their uncle snafu, which still survives and is more boilerplate-reducing than thiserror, and which I prefer, and there were fehler (2019–2020), failure (2017–2020), error-chain (2016–2020), and quick-error (2015–2021), all of which I think have been abandoned by now — except, I’m surprised to see, quick-error, the oldest of these but still getting nearly 2 million downloads per month tracked by Lib.rs.

                  1. 1

                    That’s good to know, thanks. I was only tinkering with Rust for a couple of personal projects so wasn’t aware of these libraries.

        2. 2

          That’s when I realized that error handling in the software he’s talking about - GUI desktop applications - might be very different from the software that I’m used to writing. The scalability argument at the end also kind of rang true.

          The pattern of a “main loop” that also includes the last-resort error handler – which in many cases is also the only error handler, with everything else doing try/finally as mentioned in the article – is not some sort of weird rare fringe thing, nor is it exclusive to “GUI desktop applications”. Many types of long-running programs, especially ones that may receive external input as they run (say, network daemons, web applications, etc. etc.), are built that way.

          1. 3

            Is it really that common for the only error handling to be done at the outermost event loop?

            1. 1

              In many networked services, yes.

              There might be the occasional case where, say, a blog app knows it might not find a blog entry matching the request URL and the underlying DB tool surfaces that as an exception that the app catches at that spot and transforms into a 404 response, but in general “use a finally to release any resources you were using, and otherwise let it bubble up” is a pretty standard practice, so much so that in many web frameworks the outermost main loop has code to transform various exceptions into specific HTTP response codes (example).

              1. 1

                Fair enough. GUI desktop applications and web apps then :)

                I guess these are both situations in which someone else - either the human using the desktop application or the human using the web app (or, at least, the API client) - is best placed to decide what to do about an error. In both cases the program itself only handles the error by turning it into an appropriate representation for reporting externally: either a popup error dialog or an HTTP response code.

                Presumably there’s also some logging or telemetry to help whoever is responsible for the program to deal with the underlying issue: again, this is external to the program itself.

            2. 1

              It’s a stupid version of what Erlang does well, so probably :)

              1. 1

                Actually, an Erlang supervision tree is quite a good counter-example. I guess for a really simple system you might just handle things at the very root of the tree but for most “real world” systems you’ll have multiple levels of supervisors with multiple supervision strategies.

          2. 1

            I would say it’s more common than the “run main do some work and exit” paradigm we’ve been stuck with as if everything is a unix command line program from the 70s.

            1. 3

              That’s probably true but those are hardly the only two options. Most of the software I’ve written over the last twenty years has been of the long-running event loop type, but I’ve never just slapped a single generic error handler over the outermost loop. I can see how it might make sense for a GUI desktop application, where you just want to pop up some sort of error dialog to the human user, but otherwise there’s usually some context-dependent error handling to be done nearer the point of failure.