Skip to content

Instantly share code, notes, and snippets.

@elcritch
Last active June 9, 2023 22:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save elcritch/acd2ae0cabd4805983e4de7b7e9a078d to your computer and use it in GitHub Desktop.
Save elcritch/acd2ae0cabd4805983e4de7b7e9a078d to your computer and use it in GitHub Desktop.
Omnimvore Error Handling

RFC Draft: Omnimvore Error Handling

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.

Syntax Proposal and Compiler Checks

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]

Converting Errors

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.}

More Result's style handling

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()

Considerations

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.

Option A; Enum errors as a Global

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.

Option B: Logical Representation of Error Types

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

Possible Premature Optimization

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment