[ANN] ErrorTypes.jl - Rust-like safe errors in Julia

I’m pleased to announce the inital 0.1 release of my new package ErrorTypes.jl.

If you’re familiar with Rust, ErrorTypes introduces limited versions of Rust’s Option<T> and Result<T, E> and the recoverable error handling that they provide.

If you’re not familiar with Rust, consider the Julia function maximum. When applied to an object, it can essentially do one of three things:

  • The object is not iterable or do not have elements that can be compared. maximum will fail immediately, and this will be caught in testing.

  • It will behave correctly. No problem.

  • The object will be empty, and maximum will throw an error.

The last possibility is dangerous, because it’s an edge case that is only caught in testing if the tests are thorough enough to remember the case. Therefore, maximum provides a trap for downstream users, an easy opportunity to get into trouble.

Suppose instead that maximum(::Vector{Int}) instead of returning an Int or throwing an error, returned an object of a new type Option{Int}, which contained either nothing, signifying that the function errored, or the result. Then, it would not be possible to forget the edge case, because the fact that the function is fallible is encoded in its return type. If you forget it can fail and assumes it returns an Int, you will get a MethodError.

ErrorTypes should have zero runtime cost in type-stable code (compared to a === check against nothing), so in a sense, you get the improved code robustness and safety for free. The cost you pay is that using ErrorTypes adds a little friction in development because you’ll need to construct and unwrap these “container” types such as Option. ErrorTypes does provides various convenience methods and macros to make this easier.

The same concept has been tried out in ResultTypes.jl and Expect.jl already. The difference to ErrorTypes are:

  • ErrorTypes provide more convenience methods than Expect.jl, similar to ResultTypes.jl

  • ErrorTypes provide Option - a simplified version of Result, for when there is no need to encode anything in the error value, in order to cut down on boilerplate.

  • Unlike the other packages, ErrorTypes allows you to construct a Result with arbitrary types, e.g. a Result{Int, Int}, where the error can be encoded in an Int, without the danger of mistaking the error and the result values. The two other packages both require the error value to be an Exception.

  • ErrorTypes tries to be more strict about return types to cut down on sources of error. It does not allow conversion of error types with concrete values, e.g. an Option{Int} cannot be converted to an Option{UInt}, even if these two integers can be converted to each other. Similarly, a value of T cannot be converted to a Result, instead you must construct a Ok(T) or a Err(T).

ErrorTypes builds on @mason’s SumTypes.jl, so thank you @mason, for making the package much nicer.

Please give my package a try. As always, feedback is much appreciated.

31 Likes

I’m just glad to have found a use-case for SumTypes! I think this is an interesting area of the design space to explore, and encoding information about what can go wrong in a program is the return types is a compelling approach.

5 Likes

It would be great to see this more fundamental to the language. The approach seems to strike a reasonable balance in getting people to deal with possible error conditions at the right time in the coding process.

1 Like

Perhaps - it’s still too early to tell if it’s the right way to go with Julia. If course I use ErrorTypes all the time and am quite happy about it. But there are advantages to union-types, too. If Julia gets better linting and static analysis, then we might get the best of both worlds.

The picture will be clearer when JET.jl gets more fleshed-out over the next year’s time, and perhaps integrated into VSCode.

1 Like

It would be great to see this more fundamental to the language. The approach seems to strike a reasonable balance in getting people to deal with possible error conditions at the right time in the coding process.

I’ve used Rust a fair bit lately, and I think that approach to error handling is fantastic for some scenarios. I’m using it for juliaup, and that is a piece of software that needs to properly handle all error conditions, so the way Rust forces me to think about each one and handle it is absolutely great. I can see many similar contexts where Julia is used today where this would also be useful, say some backend data processing system etc. where you really want to make sure you handle edge/error cases.

BUT, the thought that I would have to do this in my typical day-to-day data, quick exploration type of work in my research setting is scary. I teach a lot using notebooks, and if in settings like this such an approach is really not practical, IMO. So I think having this available as a package for those scenarios where it is useful is great, but I’d be very, very cautious about adding this in a more fundamental way to Julia, I think for a lot of valid use cases of Julia this wouldn’t be the right approach.

Great package!

12 Likes

I find that in my workflows, not having advance warning of error messages means I have to wait for a long procedure before finding out that there’s a mistake at the end. In that sense, it can be more practical to have explicit error handling rather than having to deal with unexpected exceptions, at least in some situations.

Supporting these workflows would probably not work very well if Result/Error were restricted to a package, because it would not be pervasive in the ecosystem and all my dependencies would continue to throw unexpected errors that I wouldn’t know to handle.

ErrorTypes.jl is neither necessary, not sufficient to catch errors in advance of a long workflow. It is instead meant to help prevent them from happening in the first place by being explicit.

julia> using ErrorTypes

julia> function safer_findfirst(f, x)::Option{eltype(keys(x))}
           for (k, v) in pairs(x)
               f(v) && return some(k) # some: value
           end
           none # none: absence of value
       end;

julia> function will_fail()
           sleep(100)
           safer_findfirst(==(1), [2, 3, 4]) + 1
       end;

julia> @time try
           will_fail()
       catch e;
           println("Shit, I just wasted my time")
       end

Shit, I just wasted my time
100.059694 seconds (64 allocations: 2.969 KiB)

If you want to try and catch errors before runtime, you should instead be looking at JET.jl

julia> using JET

julia> @time @report_call will_fail()
  7.047847 seconds (26.25 M allocations: 1.359 GiB, 10.05% gc time, 20.04% compilation time)
═════ 1 possible error found ═════
┌ @ REPL[3]:3 Main.+(Main.safer_findfirst(Main.==(1), Base.vect(2, 3, 4)), 1)
│ no matching method found for call signature (Tuple{typeof(+), Option{Int64}, Int64}): Main.+(Main.safer_findfirst(Main.==(1)::Base.Fix2{typeof(==), Int64}, Base.vect(2, 3, 4)::Vector{Int64})::Option{Int64}, 1)
└─────────────

A lot of that is just compilation overhead and gets better as more of JETs internals get compiler

julia> @time @report_call will_fail()
  0.025906 seconds (133.35 k allocations: 7.367 MiB)
═════ 1 possible error found ═════
┌ @ REPL[3]:3 Main.+(Main.safer_findfirst(Main.==(1), Base.vect(2, 3, 4)), 1)
│ no matching method found for call signature (Tuple{typeof(+), Option{Int64}, Int64}): Main.+(Main.safer_findfirst(Main.==(1)::Base.Fix2{typeof(==), Int64}, Base.vect(2, 3, 4)::Vector{Int64})::Option{Int64}, 1)
└─────────────
2 Likes

JET will undoubtedly help when it gets into VSCode.

However, many libraries raise errors for “abnormal” conditions. I have no way to know that they’ll do that, and JET won’t help. For example Base has 764 matches for ' throw\('. If the culture of Julia moved toward libraries using Result/Error instead of raising unexpected exceptions, I would know about the error conditions in advance and be able to handle them early.

That’s exactly what I mean.

1 Like

Ah. Well, then that’s going to fall into this category PSA: Julia is not at that stage of development anymore

Introducing and enforcing explicit error types into existing code in Julia base rather than throwing is very unlikely to happen because it would be too breaking.

The library ecosystem is adding new packages and functions all the time, growing much more than Base is. New functions can start using Result/Error instead of exceptions. They won’t do that if the whole Result/Error model is relegated to an obscure package.

One of the many things I like about JET is that it very naturally fits into the existing Julia ecosystem. In this case we don’t need error wrappers or to reimplement findfirst; we just use it as-is and JET can tell us what’s wrong:

julia> using JET

julia> function will_fail()
           sleep(2)
           findfirst(==(1), [2, 3, 4]) + 1
       end
will_fail (generic function with 1 method)

julia> will_fail()
ERROR: MethodError: no method matching +(::Nothing, ::Int64)
[...]

julia> @report_call will_fail()
═════ 1 possible error found ═════
┌ @ REPL[3]:3 Main.+(Main.findfirst(Main.==(1), Base.vect(2, 3, 4)), 1)
│ for 1 of union split cases, no matching method found for call signatures (Tuple{typeof(+), Nothing, Int64})): Main.+(Main.findfirst(Main.==(1)::Base.Fix2{typeof(==), Int64}, Base.vect(2, 3, 4)::Vector{Int64})::Union{Nothing, Int64}, 1)
└─────────────
(Int64, 1)

On the other hand, I don’t think JET can currently report on uncaught exceptions like you’d get from maximum([]). It could be possible to add this kind of analysis but perhaps it would generate a lot of false positives.

I definitely get why the Rust approach is attractive for more “systems” programming use cases. But when using Julia for mathematical/technical computing I don’t really like error wrappers. All that wrapping and unwrapping breaks function composition and at that point your code stops looking like mathematics.

4 Likes

Overall, I think having Base Julia default to Rust-like error handling would not be a good thing, even if it was not massively breaking, which it is. Having to deal with these ErrorTypes.jl types hurt expressiveness - it simply takes more code to do the same thing. To me, expressiveness is a huge selling point of Julia. ErrorTypes is nice when developing packages, but packages require a different coding style than scripts.

The Base Julia equivalent to ErrorTypes.jl is to return a Union, for example Union{Nothing, Some{T}}, like findfirst. This is more expressive, it enables more generic code, and it can also get caught by JET if the union is small enough for union-splitting to occur.

There is one way I think Base can change for the better, though. We could systematically go through functions like maximum that throws on a single obvious edge case, and create new trymaximum functions that return a union - precisely like we have parse and tryparse. This is not breaking, it enables people to have both safety and expressiveness, and it follows the Base convention of using union-types instead of sum types.

9 Likes

I know the general idea of error types but have never used them in practice, and I think I am not getting how to use them.

@jakobnissen How would this dummy function be rewritten to make use of error types?

function do_request()
    try
        res = HTTP.request("GET", uri, headers)
        do_stuff(res)
    catch e
        handle_error(res)
    end
end

There is no easy way to change an exception into a function that uses error values (without using try-catch which is slow and unreliable). On the other hand, it’s very easy to convert an error value into an exception.

If the exception you are catching come from the HTTP.request function itself, you need to use try/catch:

res::Result{SomeValue, SomeError} = try
    Ok(HTTP.request( ... ))
catch e
    Err(e)
end
return res

However, if the HTTP request itself returns a result/error value, I don’t think you will benefit very much form converting it to an ErrorTypes.jl Result. You could do something like this, however:

function do_request()::Result{SomeValue, SomeError}
    response = HTTP.request( ... )
    return if (response isa GoodType)
        Ok(do_stuff(response))
    else
        Err(some_error_value)
    end
end

But I don’t know about HTTP or what it can actually return, so I can’t give examples to what your Result type should look like.

2 Likes

Slow yes, especially because the compiler largely doesn’t understand exceptions in a way which can lead to optimization — handling them is left purely to the runtime.

But unreliable? I thought we’d largely fixed this in ~ julia-1.1 timeframe?

Currently It’ll throw exceptions to indicate errors related to IO. For example, things like timeouts, prematurely closed connections, SSL verification failures etc etc. There’s not any way to opt out of this behavior (indeed, some of the exceptions arise transitively from MbedTLS, Sockets and possibly other libraries).

It does offer the ability to control whether unsuccessful HTTP responses produce exceptions by setting HTTP.request(...; status_exception = false).

2 Likes