Guide

Error

Finish a boundary with a Schema-owned modeled failure.

State.Error is a terminal leaf for modeled failure. Like State.Done, it completes the state that owns it. Unlike State.Done, it preserves a typed failure as an Effect Cause.

Make failure an explicit outcome.

State.Error marks one boundary as failed and preserves its Schema-owned reason. At the root it ends the instance; nested beneath a compound state it gives that parent an error to handle.

1 · Names describe; state kinds behave

A state named Failed may still be ordinary.

The job enters a leaf named Failed, but that leaf is State.Atomic. The job remains Running, and the event's reason is not part of its terminal result.

import { State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";

const FailJob = Schema.TaggedStruct("FailJob", { reason: Schema.String });

export class Job extends State.Compound<Job>()("Job") {
  static states = States.make(() => [Working, Failed]);
}

class Working extends State.Atomic<Working>()("Working") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(FailJob, Failed),
  ]);
}

class Failed extends State.Atomic<Failed>()("Failed") {}

export const JobChart = Statechart.make(Job);

2 · Fail the root

State.Error ends the instance with a typed cause.

Failed now declares Schema.String as its error type. The transition supplies the event's reason; entering this root-level error state produces an Error snapshot that preserves it.

import { State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";

const FailJob = Schema.TaggedStruct("FailJob", { reason: Schema.String });

export class Job extends State.Compound<Job>()("Job") {
  static states = States.make(() => [Working, Failed]);
}

class Working extends State.Atomic<Working>()("Working") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(FailJob, Failed, ({ event }) => event.reason),
  ]);
}

class Failed extends State.Error<Failed>()("Failed", Schema.String) {}

export const JobChart = Statechart.make(Job);

3 · Handle a nested error

A parent may turn failure into its next state.

Failed now completes only Attempt with an error. State.error(this) lets Attempt handle that cause and move the still-running job to NeedsAttention.

import { State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";

const FailJob = Schema.TaggedStruct("FailJob", { reason: Schema.String });

export class Job extends State.Compound<Job>()("Job") {
  static states = States.make(() => [Attempt, NeedsAttention]);
}

class Attempt extends State.Compound<Attempt>()("Attempt") {
  static states = States.make(() => [Working, Failed]);
  static transitions = Transitions.make(this, ({ on }) => [on(State.error(this), NeedsAttention)]);
}

class Working extends State.Atomic<Working>()("Working") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(FailJob, Failed, ({ event }) => event.reason),
  ]);
}

class Failed extends State.Error<Failed>()("Failed", Schema.String) {}

class NeedsAttention extends State.Atomic<NeedsAttention>()("NeedsAttention") {}

export const JobChart = Statechart.make(Job);

A failure-shaped name is not an error result

The first frame enters an ordinary State.Atomic named Failed. The job remains Running because names carry domain meaning, not terminal behavior. The reason field arrived on FailJob, but this model does not preserve it as a chart result.

Use an ordinary atomic failure state when failure is simply another running phase with more events to handle. Use State.Error when the owning boundary must finish unsuccessfully.

Error at the root ends the instance

The second frame changes Failed to State.Error and gives it Schema.String as its error Schema. The transition into Failed must now construct that typed value, so it returns event.reason.

Because Failed belongs directly to the root Job, entering it terminates the chart with an Error snapshot. Its cause retains the modeled string as a failure reason. Consumers can distinguish a successful Done result from a modeled Error without parsing state names or logs.

State.Error is not an exception handler. It is authored terminal geometry for a failure the domain has chosen to represent.

Error inside a compound state completes that state

The final frame nests Working and Failed beneath Attempt. Entering Failed now completes Attempt with an error and produces State.error(Attempt)—written inside its transition declaration as State.error(this).

Attempt handles that completion by moving the root job to NeedsAttention. The cause crossed the nested boundary, but the root never entered one of its own error states, so the job remains Running. Handling, recovery, and escalation are parent decisions; terminal failure belongs to the boundary that owns the error state.

Test the boundary

  1. Remove the State.error(this) transition and identify which nested state remains terminal.
  2. Change NeedsAttention to a root-level State.Error and supply the nested cause to it.
  3. Replace Schema.String with a tagged domain error and follow the new required value through the transition.