Arrow typed errors
Arrow typed errors are a powerful way to declare the possible failure cases of a function. Because failure situations are encoded directly in the function's type (unlike exceptions), we can use type parameters to create abstractions over certain failure cases to better handle them.
Configuration
Add a dependency on dev.opensavvy.prepared:compat-arrow
to use the features on this page.
See the reference.
Info
The examples on this page use the Kotest assertion library.
Testing success or failure
We want to test the following function:
data object NegativeSquareRoot
context(Raise<NegativeSquareRoot>) //(1)!
fun sqrt(value: Double): Double {
ensure(value >= 0) { NegativeSquareRoot } //(2)!
return kotlin.math.sqrt(value)
}
- Failure conditions are declared as part of the function's signature. In this example, we use the experimental context parameter syntax.
If you do not have access to this syntax, you can also use regular extension receivers:fun Raise<NegativeSquareRoot>.sqrt(value: Double): Double { … }
. ensure
is Arrow's equivalent torequire
andcheck
: we test a condition, and raise a failure if it isfalse
.
To test a successful case, we use the failOnRaise
function:
To test a failed case, we use the assertRaises
or assertRaisesWith
functions:
test("√-1 raises") {
assertRaises(NegativeSquareRoot) { //(1)!
sqrt(-1.0)
}
}
test("√-1 raises") {
assertRaisesWith<NegativeSquareRoot> { //(2)!
sqrt(-1.0)
}
}
- Asserts that a function raises a specific value.
- Asserts that a function raises a specific type.
Error tracing
Prepared takes advantage of the Raise
DSL's tracing capabilities: when an unexpected failure happens, a proper stack trace is generated.
For example, the following code:
fun Raise<Int>.a(): Unit = raise(42)
fun Raise<Int>.b() = a()
fun Raise<Int>.c() = b()
fun Raise<Int>.d() = c()
fun Raise<Int>.e() = d()
test("Test tracing") {
failOnRaise {
e()
}
}
An operation raised 42.
at arrow.core.raise.DefaultRaise.raise(Fold.kt:239)
at foo.FailOnRaiseTestKt.a(FailOnRaiseTest.kt:28) ←
at foo.FailOnRaiseTestKt.b(FailOnRaiseTest.kt:29) ←
at foo.FailOnRaiseTestKt.c(FailOnRaiseTest.kt:30) ←
at foo.FailOnRaiseTestKt.d(FailOnRaiseTest.kt:31) ←
at foo.FailOnRaiseTestKt.e(FailOnRaiseTest.kt:32) ←
at foo.FailOnRaiseTest$1$3.invokeSuspend(FailOnRaiseTest.kt:23)
at foo.FailOnRaiseTest$1$3.invoke(FailOnRaiseTest.kt)
at foo.FailOnRaiseTest$1$3.invoke(FailOnRaiseTest.kt)
at opensavvy.prepared.suite.RunTestKt$runTestDslSuspend$2.invokeSuspend(RunTest.kt:42)
If, instead, we use a naive testing approach, like most test frameworks do:
fun Raise<Int>.a(): Unit = raise(42)
fun Raise<Int>.b() = a()
fun Raise<Int>.c() = b()
fun Raise<Int>.d() = c()
fun Raise<Int>.e() = d()
test("Test without tracing") {
val result = either {
e()
}
assertEquals(Unit.right(), result)
}
AssertionFailedError: expected:<Either.Right(kotlin.Unit)> but was:<Either.Left(42)>
at foo.FailOnRaiseTest$1$3.invokeSuspend(FailOnRaiseTest.kt:28)
at foo.FailOnRaiseTest$1$3.invoke(FailOnRaiseTest.kt)
at foo.FailOnRaiseTest$1$3.invoke(FailOnRaiseTest.kt)
at opensavvy.prepared.suite.RunTestKt$runTestDslSuspend$2.invokeSuspend(RunTest.kt:42)