An idea for concise, checked error handling in imperative languages
2016-03-20 ⋅Current error handling strategies
There are a lot of models that currently exist for error handling. Among them are exceptions (C++, Python), checked exceptions (Java, Nim), multiple return values (Elixir, Go), union types/ADTs (Haskell, Felix, Rust, OCaml [I think...]), and a mix of them all (C). However, they all have some issues that cause religious wars between their supporters:
-
Exceptions are completely unchecked. Goodness knows whether or not you are handling all the cases. Often, a function will throw an exception that you didn't even know threw anything.
-
Checked exceptions can be either painful or breakable. When a compiler implements them with 100% precision, then you can run into issues with callbacks. Does
my_function_that_takes_a_callback(callback)
not throw anything, but callback can? Too bad. When they're breakable, then that brings us back to the first problem. -
Multiple return values can be a bit verbose at times. Go code tends to be littered with
if err != nil
checks. Elixir code is MUCH better in this regard, but the errors a function can return are still somewhat unchecked. Since they use strings, you can't easily check what error exactly occurred. (Note that Elixir actually just uses a single return value that's a tuple, like{:ok, result}
or{:error, error_message}
.) -
+
-
In Haskell, functions that return
Maybe T
don't quite say what error they returned, which was a problem with multiple return values. -
In other languages, such as Felix, they can be quite a bit verbose. Rust is better, but things can still get a bit ugly at times.
-
If you ever have to deal with multiple different types of errors being thrown at once...tough luck.
+
Imperative monads
Now, I'm going to present an idea that tries to combine the best of these WITHOUT the worst. Here it goes:
Let's take a language with type inference. Say Crystal. Now, we'll add a new type T![a,b,c...]
, which means T or any of the error types a, b, c, ....
When a function wants an error to occur, it would do this:
def myfunc(a : Int32)
raise MyErrorType.new if a == 0
return a
end
This appears to be just a normal exception throw, but it really isn't. raise
here would actually just be returning MyErrorType.new
. This code would roughly be the exact same thing semantically as:
def myfunc(a)
# Using Haskell Left/Right naming.
return Left.new "invalid number #{a}" if a == 0
return Right.new a
end
In short, it's just union types, but more concise. Because of Crystal's type inference, this would make myfunc's return type Int32![MyErrorType]
.
The cool part comes with handling the errors. If this were fully union types, the code may be something like (Crystal doesn't actually have pattern matching like I show; I'm just improvising):
def myotherfunc(a)
case myfunc a
when Left error
puts "An error occurred: #{error}"
when Right result
puts "Function returned: #{value}"
end
end
However, this is where things go a completely different route:
def myotherfunc(a)
try
myfunc a
except MyErrorType as ex
puts "An error occurred: #{ex.message}"
else value
puts "Function returned: #{value}"
end
"But wait," you say, "how is this different from exceptions!?" Well, this try
is not at all like a normal try
.
Instead, the body of the try
statement MUST be an expression that returns T![E...]
. If any E
is returned, then it goes to the appropriate except
block. If no error occurred, then it jumps to the else
block, giving it the value of type T
.
The key difference here is that you can't just do something like 1 + myfunc(1)
; an error would occur since you're trying to add 1 (of type Int32
) to myfunc(1)
(of type Int32![MyErrorType]
).
Another major difference is what happens if an except
block doesn't cover a possible error. For instance, if myfunc
were changed to:
def myfunc(a : Int32)
raise MyErrorType.new if a == 0
# A new error:
raise MyOtherErrorType.new if a < 0
end
What would happen to myotherfunc
? It wouldn't compile!
If there would possibly be no matching except
block, then the compiler would treat myotherfunc
as if it said:
def myotherfunc(a)
try
myfunc a
except MyErrorType as ex
puts "An error occurred: #{ex.message}"
# Inserted by the compiler
except MyOtherErrorType as ex
raise ex # Re-raise the error
else value
puts "Function returned: #{value}"
end
Now myotherfunc
is inferred to return Int32![MyOtherErrorType]
. In order to fix it, you can just do:
def myotherfunc(a)
try
myfunc a
# Take either type of error.
except MyErrorType | MyOtherErrorType as ex
puts "An error occurred: #{ex.message}"
else value
puts "Function returned: #{value}"
end
You could also omit any except
clause. For example:
def myotherfunc(a)
try
myfunc a
# No except clauses
else value
puts "Function returned: #{value}"
end
This would be equivalent to:
def myotherfunc(a)
try
myfunc a
# Inserted by compiler.
except MyErrorType | MyOtherErrorType as ex
raise ex
else value
puts "Function returned: #{value}"
end
In addition, this can be an expression. If an error occurs, the function instantly returns; otherwise, the value of the else
block is returned:
def myotherfunc(a)
result = try
myfunc a
except MyErrorType | MyOtherErrorType as ex
puts "An error occurred: #{ex.message}"
else value
puts "Function returned: #{value}"
value + 1
puts result
end
If the else
block is ommitted, then the non-error value is returned:
def myotherfunc(a)
result = try
myfunc a
except MyErrorType | MyOtherErrorType as ex
puts "An error occurred: #{ex.message}"
# No else block; same thing as putting:
# else value
# value
puts result
end
Now you can combine all this to get a nice shorthand:
def myotherfunc(a)
return try myfunc a
end
The compiler would basically desugar that into:
def myotherfunc(a)
return
try
myfunc a
except MyErrorType | MyOtherErrorType ex
raise ex
else value
value
end
As an added benefit, you can chain !
uses, so T![E1]![E2]
would be converted to T![E1,E2]
. This seems useless, but it's very handy with generic types.
I call all this:
Imperative monads
Differences from other strategies
-
Exceptions are unchecked. On the other hand, with imperative monads, if you try to use a function that can error in an expression, you'll get a type error (e.g.
1 + myfunc(2)
). In addition, if you forgot to handle an error type, you'll still get a type error. -
Unlike checked exceptions, imperative monads, when combined with type inference as shown above, don't necessarily require you to write out every single possible error. Callbacks would work as excepted, since errors are really just return values with some added awesomeness.
-
Imperative monads have lots of sugar to handle errors, so it's as safe as Go (if you can call it that...). In contrary to Elixir, imperative monads allow you to create your own error types, just like Go or normal exceptions. You can encode all the information you want into the type itself.
-
Union types can be a bit messy in imperative languages, but imperative monads were designed exactly for that. They're not verbose, and it would be almost impossible to end up with nested errors.
Last but not least, since errors are again types, there's lots of room for potential compiler optimizations.
Sequencing
This was actually not present in the original post, but someone pointed it out, so I'm adding it here. (I actually can't believe I forgot this, considering this is easily one of my error handling deal-breakers...)
What happens to error sequences? Exceptions are great for this:
try:
x = something()
something_else(x)
except IOError: # If any of the expressions result in an IOError.
print('Error occurred!')
Well, that could go something like this:
try
x = try something
something_else x
except IOError as ex
puts "Error occurred!"
What exactly does this do?
The core idea is that, when try
's are nested, errors propogate up. This code does what you might expect; if something
returns an error type, it causes an error. This error is then propogated up to the outer try
, which would forward it to the except
block.
Issues
Honestly, the only issue I can think of is just with sequences and their transformation functions. If you have a functional language, you'll need multiple versions of every sequence function, like Haskell's map
vs mapM
and filter
vs filterM
.