DEV Community

Patrick Roza
Patrick Roza

Posted on • Originally published at patrickroza.com on

When to return a Result.Error and when to throw an Exception?

In the previous part I introduced the Result class as a means of control flow and error handling. Now I will focus on when to use the Result class to return an Error, and when to throw instead.

There appears to be quite some different opinions on the subject:

  1. Always throw an Error
  2. Always throw errors and then only wrap them when you want to handle them
  3. Only return recoverable errors, throw the rest
  4. Return expectable errors, throw (or pass through) the rest.
  5. Return every error, never throw. (Wrap any library error)

I am mostly in Camp 4, although I think 3 and 4 actually mean the same thing, most of the time. As always, it depends on the use case.

I would generally return: Domain errors and certain Infrastructure errors

  • SoldOut, RoomAlreadyBooked, PaperJammed, InvalidProductCode, CodeAlreadyRedeemed, PaymentMethodFailed
  • ValidationError, InvalidStateError, ForbiddenError, AuthenticationRequiredError
  • APIError/DBError: RecordNotFound, RecordAlreadyExists, (Optimistic)LockError, CouldNotAcquireLockError, ConnectionError, RemoteServiceError

I would throw exceptions (panics):

Programmer errors:

  • ArgumentException
  • NullException
  • DivideByZeroException

Corruption errors, e.g: SerializationException

  • The runtime will also throw various errors, for instance in case of StackOverflow or OutOfMemory.Such errors should abandon the current flow, and raise an exception, probably caught at the highest level, usually for error logging.

In the end, it depends on what you're building.

Catch & Wrap ‘all the tings’?

Does this mean you should catch and wrap all third party errors? No, it follows the same principals; if it falls into expectable errors like one that represents api call status codes like 400 or 404, or e.g a database record not found; wrap them.

Why return instead of throw

a technique that allows you to capture errors elegantly, without contaminating your code with ugly conditionals and try/ catch statements.

From the book “Domain modeling made functional”; A great read, really recommended!

  • try/catch/finally is verbose, indenting/nesting at least 1 scope deep, often also leading to multiple levels deep (within 1 or more functions)
  • Errors become part of the return type signature; automatic documentation, and compiler assistance when missing to handle one
  • It enables composability
  • Throwing errors is expensive (stack traces etc), while for the error cases you would return, generally I see no use for stack traces. Now I’ll be the first to say: don’t prematurely optimize, but if you can design your application from the ground up to be performant, even in the expectable error cases; that’s great.

As a side note, in javascript:

  • There is no ‘beautiful’ built-in way to handle different error types differently, other than if statements or switch, unlike e.g in C# or Python.
  • There is no ‘beautiful’ built-in way to document the errors that may occur, and have type-safety for them, unlike in e.g Java.

Other examples of implementations that favor return status over Exceptions

  • fetch : The fetch implementation will only throw on a Network Error. It will use an ok Boolean that will be false on a status code > 400. It isn’t as elegant as the Result class though.
  • Promise : once resolved or rejected; either success or error. Has the benefit of await support however.

Relationship to "Checked" Exceptions

https://stackoverflow.com/questions/613954/the-case-against-checked-exceptions

https://softwareengineering.stackexchange.com/questions/150837/maybe-monad-vs-exceptions

Perhaps summated as:

With great power comes great responsibility

I think the difference is that Checked exceptions are a forced concept , but Result can be an application level decision you make, you opt-in.

In fact, if you don't want to handle a Result.Error, you can just let it bubble up, there's no requirement to handle it, unless you explicitly decide "this is the place I want to handle each and every exception in a certain way", and e.g use Typescript's "exhaustive switch block" or similar.

Finally, in Typescript and F# there are Union types that help you "fold" errors into higher order ones, unlike with Checked exceptions in e.g Java.

Violation of the Open/Closed Principle?

https://stackoverflow.com/questions/54882275/do-checked-exceptions-violate-the-open-closed-principle

OCP appears to apply to modules, not methods. Also I think that if you add a new exception, you are more "silently" breaking the contract. At least Return error types don't lie ;-)

Error handling samples

Handle Success, or Error case

doSomething()
  .pipe(match(
    value => ctx.body = value,
    err => ctx.statusCode = 500,
  ))

Fallback to a default value

const result = doSomething()
  .pipe(orDefault(some-default-value))
// if doSomething() fails, use “some-default-value” instead.

High level REST api error handler (more elegant ideas welcome!)

if (err instanceof RecordNotFound) {
  ctx.body = { message }
  ctx.status = 404
} else if (err instanceof CombinedValidationError) {
  const { errors } = err
    ctx.body = {
    fields: combineErrors(errors),
    message,
  }
  ctx.status = 400
} else if (err instanceof FieldValidationError) {
  ctx.body = {
    fields: { [err.fieldName]: err.error instanceof CombinedValidationError ? combineErrors(err.error.errors) : err.message },
    message,
  }
  ctx.status = 400
} else if (err instanceof ValidationError) {
  ctx.body = { message }
  ctx.status = 400
} else if (err instanceof InvalidStateError) {
  ctx.body = { message }
  ctx.status = 422
} else if (err instanceof ForbiddenError) {
  ctx.body = { message }
  ctx.status = 403
} else if (err instanceof OptimisticLockError) {
  ctx.status = 409
} else if (err instanceof CouldNotAquireDbLockError) {
  ctx.status = 503
} else if (err instanceof ConnectionError) {
  ctx.status = 504
} else {
  // Unknown error
  ctx.status = 500
}

Which would be accompanied by a catch-all exception handler for status 500, and logging:

try {
  // execute the workflow
  // handle success and error result cases
} catch (err) {
  logger.error(err)
  ctx.status = 500
}

Source

As always you can also find the full framework and sample source at patroza/fp-app-framework

Other reads

What's Next

Next in the series, I plan to explore my ideal vision of the future, once we receive pipe operator |> and improved Generator typing support in Typescript ;-)

Latest comments (0)