Skip to content

specto-dev/belay

Repository files navigation

Belay — Robust Error-Handling for Kotlin and Android

CI Maven Central code size Kind Speech

Code Complete: A Practical Handbook of Software Construction, on error-handling techniques:

Consumer applications tend to favor robustness to correctness. Any result whatsoever is usually better than the software shutting down. The word processor I'm using occasionally displays a fraction of a line of text at the bottom of the screen. If it detects that condition, do I want the word processor to shut down? No. I know that the next time I hit Page Up or Page Down, the screen will refresh and the display will be back to normal.

Belay is a Kotlin error-handling library which favors robustness. It serves two purposes:

  • Detect errors early during development using assertions.
  • Gracefully recover from errors when they occur in production.

Installation

In your project's build.gradle:

dependencies {
    implementation("dev.specto:belay:0.3.0")
}

Declare a top level variable to run expectations:

val expect = Expect()

It can be named anything, but for the purposes of this documentation we'll assume it's named "expect".

Next, set the top level expectation handler as early as possible during your program's initialization:

fun main() {
    expect.onGlobalFail = object : GlobalExpectationHandler() {
        override fun handleFail(exception: ExpectationException) {
            if (DEBUG) throw exception
            else log(exception.stackTraceToString())
        }
    }
    
    //
}

The global handler will be invoked when any expectations fail. Of course it's possible, and often desirable, to handle expectations individually or by subgroups, but the global handler is the ideal place to throw exceptions during development—effectively turning expectations into assertions—so that failures can be noticed and addressed immediately. In production it can be used to log all errors, regardless of how they are ultimately handled, so that they can be fixed at a later date.

⚠️ Do not call expect from the global handler, it could cause an infinite loop if that expectation fails.

Writing Expectations

The expect variable provides a host of utilities to write expectations and specify how they should be handled. They fall in 3 main categories.

Global Expectations

Global expectations only invoke the global handler when they fail.

expect(condition, optionalErrorMessage) // shorthand for expect.isTrue(…)
expect.fail("error message", optionalCause)
expect.isTrue(condition, optionalErrorMessage)
expect.isFalse(condition, optionalErrorMessage)
expect.isNotNull(value, optionalErrorMessage)
expect.isNull(value, optionalErrorMessage)
expect.isType<Type>(value, optionalErrorMessage)

Unless the global handler interrupts the program, it will proceed even when these expectations fail. Therefore, they are meant to be used when the program can proceed even when the expectations fail. For example:

fun cleanup() {
    expect.isNotNull(configuration)
    configuration = null
}

Locally-Handled Expectations

Locally-handled expectations invoke the global handler when they fail, and then a locally-defined error-handling function.

expect(condition, optionalErrorMessage) {
    // Handle the expectation failing.
    // Does not need to return or throw an exception.
}

expect.isTrue(condition, optionalErrorMessage) {
    // Handle the expectation failing.
    // Must return or throw an exception which enables smart casting.
    return
}
expect.isFalse(condition, optionalErrorMessage) { … }
val nonNullValue = expect.isNotNull(value, optionalErrorMessage) { … }
expect.isNull(value, optionalErrorMessage) { … }
val valueCastToType = expect.isType<Type>(value, optionalErrorMessage) { … }

A custom error message can be provided to all these functions.

This is great for one-off error-handling:

fun startTime(): Long {
    //
    
    expect(startTime >= 0, "startTime was negative") {
        startTime = 0
    }
    
    return startTime
}

fun animate() {
    expect.isNotNull(animator) { return }
    
    //
    animator.animate(…)
}

Expectation Blocks

Often the same error-handling strategy can be used across individual functions or blocks of code. Expectation blocks make this easy.

expect(blockHandler) {
    fail("error message", optionalCause)
    isTrue(condition, optionalErrorMessage)
    isFalse(condition, optionalErrorMessage)
    isNotNull(value, optionalErrorMessage)
    isNull(value, optionalErrorMessage)
    isType<Type>(value, optionalErrorMessage)
}

A custom error message can be provided to all these functions.

Several block handlers are offered out of the box.

Continue does nothing when an expectation fails (besides invoking the global handler):

fun stop() = expect(onFail = Continue) {
    isTrue(isActive, "time machine was not active when stop was called")
    isNotNull(configuration)
    isNotNull(controller)
    
    isActive = false
    configuration = null
    controller = null
    //
}

Return immediately returns a default value when an expectation fails:

fun startTime(): Long = expect(Return(0)) {
    //
}

ReturnLast returns the last value returned or a default if no value has been returned yet:

fun pixelColor(x: Int, y: Int): Color = expect(ReturnLast(Color.BLACK)) {
    //
}

Throw throws an exception when an expectation fails:

fun startRadiationTreatment() = expect(Throw) {
    //
}

All the provided block handlers allow an arbitrary function to be executed when an expectation fails:

expect(Return { disableController() }) {
    //
}

Block handlers which interrupt the program, like Return, ReturnLast and Throw, can also treat exceptions as failed expectations:

fun startTime(): Long = expect(Return(0), catchExceptions = true) {
    // All exceptions thrown by this function will be automatically caught
    // and handled by the expectation handler as a failed expectation.
}

It's also possible, and easy, to write your own expectation handler.

Writing Expectation Handlers

Writing custom expectation handlers is particularly useful when the same custom logic needs to be reused across a program. There are two types of expectation handlers: those that may interrupt the program when an expectation fails, and those that definitely do.

Handlers who may interrupt the program when an expectation fails, like Continue, must extend ContinueExpectationHandler. Handlers that definitely interrupt the program, for example by returning early or throwing an exception, like Return, ReturnLast or Throw, should extend ExitExpectationHandler. This distinction serves to enable smart casting for ExitExpectationHandler expectations.

You've actually already implemented a handler which uses the same interface as ContinueExpectationHandler, the global handler. The ExitExpectationHandler interface is very similar, here's an example implementation:

class DisableControllerAndReturn<T>(
    returnValue: T,
    also: ((exception: ExpectationException) -> Unit)? = null
) : ExitExpectationHandler<T>() {

    private val controller: Controller by dependencyGraph

    override fun handleFail(exception: ExpectationException): Nothing {
        controller.disable(exception.message)
        also?.invoke(exception)
        returnFromBlock(returnValue)
    }
}

Contributing

We love contributions! Check out our contributing guidelines and be sure to follow our code of conduct.