Error Handling
Form validation that shows an inline message. An API call that returns a rejection the UI must acknowledge. A business rule that blocks invalid input before it reaches the database. These are domain errors — outcomes your logic actively produces, not crashes or programming mistakes.
Anchor makes domain errors a first-class type parameter: Anchor<R, S, Err>. Every failure mode your feature can produce is declared in the signature, routed to a handler you register at construction time, and exhaustively matched by the compiler. Silent failures become impossible to write.
Declaring the error contract
Define a sealed interface for your domain errors and pass it as the third type parameter to Anchor<R, S, Err>. Then register onDomainError and defect in create() — this is the full error contract, declared once, in one place.
sealed interface ConfigError {
data object EmptyInput : ConfigError
data class TooLong(val maxLength: Int) : ConfigError
}
typealias ConfigAnchor = Anchor<ConfigEffect, ConfigState, ConfigError>
fun RememberAnchorScope.configAnchor(): ConfigAnchor =
create(
initialState = ::ConfigState,
effectScope = { ConfigEffect() },
onDomainError = { error ->
when (error) {
ConfigError.EmptyInput ->
reduce { copy(errorMessage = "Text cannot be empty") }
is ConfigError.TooLong ->
reduce { copy(errorMessage = "Text exceeds ${error.maxLength} characters") }
}
},
defect = { t ->
reduce { copy(errorMessage = "Unexpected error: ${t.message}") }
},
)
Sealed interfaces give you exhaustive when expressions — the compiler tells you if you add a new error variant and forget to handle it. The onDomainError lambda receives ErrorScope<R, S> as its receiver, which provides reduce, effect, post, and emit — but intentionally not raise or orDie. Error handlers are terminal; they cannot re-raise.
Both handlers are optional. Omitting onDomainError means an unhandled raise() crashes the coroutine. Omitting defect lets unexpected exceptions propagate normally.
Domain errors vs defects
Anchor separates failures into two categories, and the distinction matters.
Domain errors are expected outcomes your business logic knows about — validation failures, auth rejections, resource constraints. You declare them as Err, raise them with raise() or ensure(), and handle them in onDomainError. They cancel the current action cleanly without crashing the coroutine scope.
Defects are unexpected failures your code did not anticipate — a null that should never be null, an uncaught exception from a third-party library, a broken invariant. You handle them in defect, or escalate them intentionally with orDie().
This distinction is why ErrorScope omits raise and orDie. A handler that could re-raise would create unbounded recursion — so the type system makes it impossible.
Strategy 1 — Propagate with raise() and ensure()
Call raise(error) inside an action to short-circuit execution. Control jumps immediately to onDomainError. This is the right strategy when the error should be shown to the user and you want the handler to decide what the UI looks like.
suspend fun ConfigAnchor.updateText(text: String) {
ensure(text.isNotBlank()) { ConfigError.EmptyInput }
ensure(text.length <= 100) { ConfigError.TooLong(maxLength = 100) }
withContext(Dispatchers.Default) {
delay(1000)
reduce { copy(text = text.trim(), errorMessage = null) }
}
}
ensure(condition) { error } is shorthand for if (!condition) raise(error). When either guard fails, the action stops and onDomainError runs with the corresponding ConfigError.
Strategy 2 — Recover locally with recover { }
Wrap a block in recover { } to catch any raise() inside it and turn the outcome into a Recover<Err, T> value that you handle inline. The error never reaches onDomainError.
suspend fun ConfigAnchor.updateTextClamped(text: String) {
val result = recover {
ensure(text.isNotBlank()) { ConfigError.EmptyInput }
ensure(text.length <= 100) { ConfigError.TooLong(maxLength = 100) }
text.trim()
}
val validated: String = when (result) {
is Recover.Ok -> result.value
is Recover.Error -> when (val err = result.error) {
is ConfigError.TooLong -> text.take(100).trim() // silently clamp
ConfigError.EmptyInput -> return // nothing to do
}
}
withContext(Dispatchers.Default) {
delay(1000)
reduce { copy(text = validated, errorMessage = null) }
}
}
Recover has two variants: Recover.Ok(value) and Recover.Error(error). Useful helpers on it:
| Helper | Returns |
|---|---|
getOrNull() |
T? — value or null |
getErrorOrNull() |
Err? — error or null |
getOrElse { fallback } |
T — value or fallback |
fold(onError, onOk) |
V — transform either branch |
getOrRaise() |
T — value or re-raises the error |
Use local recovery when you want to apply a business rule silently (clamp, default, skip) without surfacing the error in the UI.
Strategy 3 — Escalate with orDie()
orDie(error) escalates a domain error to the defect handler, bypassing onDomainError. Use it for programmer mistakes — broken invariants, unexpected nulls — where the error is not something a user can fix.
suspend fun MyAnchor.processItem(id: Int) {
val item = effect { repository.findById(id) }
?: orDie(MyError.ItemNotFound(id)) // should never happen in production
reduce { copy(current = item) }
}
Domain error vs defect
raise() is for expected, recoverable errors (validation failures, network errors a retry might fix).
orDie() is for unexpected failures that indicate a bug.
The defect handler runs for orDie() calls and for any uncaught Throwable from your action.
The ErrorScope constraint
Inside both onDomainError and defect, your receiver is ErrorScope<R, S> — a type alias for BaseAnchorScope<R, S>. This scope provides:
reduce { }— update stateeffect { }— run side effectspost { }— emit a signalemit { }— emit an event
It deliberately omits raise() and orDie(). This is an architectural constraint: error handlers are final. A handler that could itself raise would create unbounded recursion. If you need conditional logic in a handler, express it with if/when and calls to reduce or post.
Cancellable interactions
When an action uses cancellable(key) { ... } and a raise() fires inside the cancellable block, Anchor records the RaisedException and propagates it after the coroutine job completes. This means onDomainError is still invoked, but only once the cancellable scope has fully wound down. The handler sees the same error as if it had been raised outside a cancellable block.
Choosing a strategy
| Scenario | Strategy |
|---|---|
| User input fails a validation rule, show error message | ensure() → onDomainError |
| Network call fails, show error state | raise() → onDomainError |
| Silently correct bad input (clamp, trim, default) | recover { } with local handling |
| Try something, fall back to a default value | recover { }.getOrElse { default } |
| A null that should never be null in production | orDie() → defect |
| Unexpected exception from a third-party library | caught automatically → defect |
Testing error handling
The anchor-test DSL captures all error events. Use these assertions in your verify block:
| Assertion | What it checks |
|---|---|
assertRaise { error } |
raise() was called with this error |
assertDomainError { error } |
onDomainError handler was invoked |
assertOrDie { error } |
orDie() was called with this error |
assertDefect { throwable } |
defect handler was invoked |
Testing strategy 1 (propagate)
@Test
fun `updateText with empty input raises EmptyInput domain error`() {
runAnchorTest(RememberAnchorScope::configAnchor) {
given("initial state") {
initialState { ConfigState() }
}
on("updating with blank text") { updateText("") }
verify("EmptyInput domain error handled and state reflects error message") {
assertRaise { ConfigError.EmptyInput }
assertState { copy(errorMessage = "Text cannot be empty") }
assertDomainError { ConfigError.EmptyInput }
}
}
}
assertRaise verifies the raise() call happened. assertDomainError verifies the onDomainError handler ran. assertState verifies the handler updated the state correctly. All three together confirm the full propagation path.
Testing strategy 2 (recover locally)
@Test
fun `updateTextClamped silently truncates instead of propagating TooLong`() {
val longText = "a".repeat(101)
runAnchorTest(RememberAnchorScope::configAnchor) {
given("initial state") {
initialState { ConfigState() }
}
on("updating clamped with text over 100 chars") { updateTextClamped(longText) }
verify("error caught locally, state updated with truncated text") {
assertRaise { ConfigError.TooLong(maxLength = 100) }
assertState { copy(text = "a".repeat(100)) }
// no assertDomainError — the handler was NOT invoked
}
}
}
The absence of assertDomainError is intentional and meaningful: it confirms the error was handled locally and never reached onDomainError.
When there are no domain errors
If a feature has no business-rule violations to surface, drop the Err type parameter entirely. PureAnchor<R, S> is a typealias for Anchor<R, S, Nothing>, and Nothing has no inhabitants — so raise() becomes a compile error, not a runtime one.
class CounterEffect : Effect
typealias CounterAnchor = PureAnchor<CounterEffect, CounterState>
fun RememberAnchorScope.counterAnchor(): CounterAnchor =
create(
initialState = ::CounterState,
effectScope = { CounterEffect() },
)
onDomainError is simply omitted because there is no Err type to match against. If you later discover the feature does need typed errors, swap PureAnchor for Anchor<R, S, YourError> and add onDomainError to create().