This RFC presents an attempt to unify parts of the syntatical styles between exceptions vs results based error handling techniques. The two styles can be implemented in a somewhat similar fashion under the covers, but do offer quite different ergonomics. In an ideal world we can take the best from both styles as fits a given situation.
The core idea of this RFC is to present a gradual typing like system for enhancing error typing. This provides attempts to provide a best of all worlds and let folks decide how strictly they want to track their error types in a given set of code.
Another idea is to provide a seamless integration from full exceptions to enum based error codes. While it provides nice ergonomics, this may not be desirable at an implementation level though.
Here's a Nim procedure with enhanced error tracking:
type IOError = ref of CatchableException
proc recv*(socket: Socket): string | IOError =
discard "..."
This syntax is optional and similar to gradual typing. We use recv
in a normal function:
proc getData*(socket: Socket, size: int): string =
result = ""
while result.len() < size:
result.add socket.recv()
This works like how a current Nim procedure will work, eseentially ignoring the error and bubbling it up. This is great for scripting or sections of a code base where it wouldn't make sense to handle an error. However the API does explicity show that the error is possible. Users downstream of getData
would get an type of string | Exception
.
Let's extend this and upgrade our error handling game:
proc getData*(socket: Socket, size: int): string | IOError =
result = ""
while result.len() < size:
result.add socket.recv()
It's the same as before but explicitly marking the error type. This isn't (yet) an error but causes the compiler to produce warnings:
/Users/user/projs/demo/tests/tdata.nim(10, 0) Hint: 'socket.recv()' returns an error but is not handled [XUnhandledError]
This allows the API to be annotated, but doesn't require the programmer to annotate their program. They could even turn the warning off in their project. Note however that this would produce an error:
proc getData*(socket: Socket, size: int): string | DataError = ...
/Users/user/projs/demo/tests/tdata.nim(10, 0) Hint: 'socket.recv()' returns an IOError but DataError is expected [XIncorrectError]
Alternatively this would be valid but produce a warning:
proc getData*(socket: Socket, size: int): string | CatchableError = ...
/Users/user/projs/demo/tests/tdata.nim(7, 0) Hint: 'getData()' returns a more generic error than is produced [XUnderSpecifiedError]
So far we've mostly been building off exception style handling. Let's introduce some results style error handling. This example lets us annotate and let the compiler know we considered this error:
proc getData*(socket: Socket, size: int): string | IOError =
result = ""
while result.len() < size:
result.add string(socket.recv())
Or using method like syntax:
result.add socket.recv().string
Now the compiler no longer produces a warning for XUnhandledError
. Further if XUnhandledError
is set to be a compiler error, then we've statisfied that scenario as well.
The traditional try/except
would work as well to silence the warnings. It's possible that overloading Nim's "type conversion" would be too confusing, so a more traditional results synatx like try?
or just ?
might be preferable. The author prefers the type conversion overload as it reinforces treating errors as values even when they're exceptions.
It's also possible to enforce the stricter compile time error per module:
{.push: strictErrors.}
This section borrows somewhat from Swift's error enums.
type
DataError = enum
BadIO
BadURL
{.push: strictErrors.}
proc initDataError(err: ref IOError): DataError = BadIO
proc initDataError(err: URLParseError): DataError = BadURL
Now we can do this:
proc getData*(socket: Socket, size: int): string | DataError =
result = ""
while result.len() < size:
let val = try: socket.recv()
except err: raise initDataError(err)
result.add val
Possible alternative syntax:
let val = try: socket.recv()
throw err: initDataError(err)
If we want to capture the value into a compound error type we can do one of the following:
proc getData*(socket: Socket, size: int): string | DataError =
result = ""
while result.len() < size:
let val = try socket.recv()
if val.ok():
result.add(val.string)
else:
return BadIO
Another alternative using catch
instead of the try
keyword with possible pattern matching:
let val = catch socket.recv()
if val as string(x):
result.add x
else:
return BadIO
My prefererred form of this:
let val = socket.recv().catch()
The format above implies that Error be modified to become a logical variant/case type. Meaning the exception handling would need to be able to be treated either a ref Exception or an enum type.
This poses some challenges on the backend implementation. However, results based error handling is generally written assuming no allocations are required. This might be a premature point however, as exceptions are already broadly used and can be made quite efficient even with allocations.
Still, limiting value errors to enum's would enable explorations in these areas more feasible.
It may be possible to treat enum errors as a global variable which could be checked and converterd into a ref exception or vice versa. The semantics for this haven't been fully worked out.
Under the covers the types would be variant types, with the type checking handled using the effect system or another similar mechanism.
Here's a rough sketch of how this could look:
type
ErrorType* = enum
Exception
ErrorEnum
Error* = object
case kind*: ErrorType
of Exception:
exception*: ref Exception
of ErrorEnum:
enumValue*: int
enumId*: int
Something like this migh tbe possible with --exceptions:goto
or --exceptions:quirky
.
Error* = object
id*: int32
value*: pointer
proc isRefException(err: Error): bool = err.id == 0
proc isErrorEnum(err: Error): bool = err.id == 1
proc getErrorEnum(err: Error): bool =
assert err.isErrorEnum()
err.id - 1
proc getRefException(err: Error): bool =
assert err.isRefException()
cast[ref Exception](err.val)