Skip to main content
A task is defined by calling Task.define(config). You provide schemas for state and events, then implement handler functions that receive a TaskContext<S> and run as Effect programs.

Task.define(config)

import { Task } from "@durable-effect/task"

const myTask = Task.define({
  state: MyStateSchema,
  event: MyEventSchema,
  onEvent: (ctx, event) => Effect.gen(function* () { /* ... */ }),
  onAlarm: (ctx) => Effect.gen(function* () { /* ... */ }),
  // optional:
  onError: (ctx, error) => Effect.gen(function* () { /* ... */ }),
  onClientGetState: (ctx, state) => Effect.gen(function* () { /* ... */ }),
})

Config parameters

state
PureSchema<S>
required
Schema for the task’s persisted state. Must be a pure schema — no service dependencies in encoding or decoding.
event
PureSchema<E>
required
Schema for incoming events. Also must be pure.
onEvent
(ctx: TaskContext<S>, event: E) => Effect<void, EErr, R>
required
Runs when an event is sent to this task. Receives the decoded event and a full TaskContext<S> for reading and writing state, scheduling alarms, and more.
onAlarm
(ctx: TaskContext<S>) => Effect<void, AErr, R>
required
Runs when a scheduled alarm fires. The alarm bookmark is cleared before this handler is called.
onError
(ctx: TaskContext<S>, error: EErr | AErr) => Effect<void, OErr, R>
Optional. Catches errors thrown by onEvent or onAlarm. If not provided, handler errors propagate as TaskExecutionError. The handler can still call ctx.purge() to clean up state on failure.
onClientGetState
(ctx: TaskContext<S>, state: S | null) => Effect<S | null, GErr, R>
Optional. Intercepts external getState() calls. Useful for redacting sensitive fields or computing derived fields before returning state to callers. Does not affect ctx.recall() inside other handlers.

TaskContext<S>

Every handler receives a TaskContext<S> as its first argument. This is the only API you need inside a handler.

State

MethodSignatureDescription
recall()() => Effect<S | null, TaskError>Read the currently persisted state. Returns null if no state has been saved yet.
save(state)(state: S) => Effect<void, TaskError>Write the full state.
update(fn)(fn: (s: S) => S) => Effect<void, TaskError>Read-modify-write. If current state is null, the update is a no-op.

Scheduling

MethodSignatureDescription
scheduleIn(delay)(delay: Duration.Input) => Effect<void, TaskError>Schedule an alarm relative to now. Accepts any Duration.Input, e.g. "5 seconds", "1 minute".
scheduleAt(time)(time: Date | number) => Effect<void, TaskError>Schedule an alarm at an absolute timestamp.
cancelSchedule()() => Effect<void, TaskError>Cancel the pending alarm.
nextAlarm()() => Effect<number | null, TaskError>Get the scheduled alarm timestamp (milliseconds), or null if none.

Lifecycle

MethodSignatureDescription
purge()() => Effect<never, PurgeSignal>Delete all persisted state and cancel any scheduled alarm. The effect never succeeds — it always short-circuits with a PurgeSignal that the framework catches internally.

Identity

PropertyTypeDescription
idstringThe instance ID passed to send().
namestringThe task name (the key used in createTasks).

Schemas must be pure

State and event schemas cannot have service dependencies for encoding or decoding. They must satisfy PureSchema<T>:
// Good — pure schemas
const MyState = Schema.Struct({ count: Schema.Number })
const MyEvent = Schema.Struct({ _tag: Schema.Literal("Go"), value: Schema.String })

// Bad — a schema that requires services to decode won't compile with Task.define()
This constraint ensures task data can be serialized independently of the Effect runtime.

Error handling

Define onError to recover from handler failures:
const myTask = Task.define({
  state: MyState,
  event: MyEvent,

  onEvent: (_ctx, _event) => Effect.fail(new Error("something broke")),
  onAlarm: () => Effect.void,

  onError: (ctx, error) =>
    Effect.gen(function* () {
      // error is the raw caught value from onEvent or onAlarm
      yield* ctx.save({ count: -1 })
    }),
})
If onError is not provided, handler errors surface as TaskExecutionError. You can also call ctx.purge() inside onError to clean up state as part of the error recovery path.

Client state hook

Use onClientGetState to transform the state that external callers see when they call getState(). The hook runs only on external state reads — it does not affect ctx.recall() inside your handlers.
const MyState = Schema.Struct({
  count: Schema.Number,
  internalToken: Schema.String,
})

const myTask = Task.define({
  state: MyState,
  event: MyEvent,

  onEvent: (ctx, _event) =>
    Effect.gen(function* () {
      yield* ctx.save({ count: 0, internalToken: "secret-123" })
    }),

  onAlarm: (ctx) =>
    Effect.gen(function* () {
      const current = yield* ctx.recall()
      // recall() returns the full state — hook is NOT invoked here
      console.log(current?.internalToken) // "secret-123"
    }),

  // Only runs when an external client calls getState()
  onClientGetState: (ctx, state) =>
    Effect.gen(function* () {
      if (state === null) return null
      return { ...state, internalToken: "[redacted]" }
    }),
})
Common uses:
  • Redact sensitive fields before returning to the client
  • Compute derived fields (e.g. progress from internal counters)
  • Merge data from external services (when combined with withServices)

Tasks with service dependencies

If your handlers need Effect services, use withServices() to provide a layer before passing the definition to createTasks. This eliminates the R type parameter:
import { withServices } from "@durable-effect/task"

const myTask = Task.define({
  state: MyState,
  event: MyEvent,
  onEvent: (ctx, event) =>
    Effect.gen(function* () {
      const db = yield* DatabaseService
      // ...
    }),
  onAlarm: () => Effect.void,
})

// Provide the layer to eliminate R
const myTaskResolved = withServices(myTask, DatabaseServiceLive)

export const { TasksDO, tasks } = createTasks({
  myTask: myTaskResolved,
})
Alternatively, you can use registerTaskWithLayer(definition, layer) when building a test registry manually.

Multiple tasks in one DO

Pass multiple definitions to createTasks. They share a single Durable Object class but each task type maintains its own isolated state per instance ID:
const counter = Task.define({ /* ... */ })
const emailer = Task.define({ /* ... */ })
const scheduler = Task.define({ /* ... */ })

export const { TasksDO, tasks } = createTasks({ counter, emailer, scheduler })

// Each accessor is typed to the correct state and event shapes
const c = tasks(env.TASKS_DO, "counter")    // TaskHandle<CounterState, CounterEvent>
const e = tasks(env.TASKS_DO, "emailer")    // TaskHandle<EmailState, EmailEvent>
const s = tasks(env.TASKS_DO, "scheduler")  // TaskHandle<SchedulerState, SchedulerEvent>

Full example

The counter task from the quick start, with onError added:
import { Effect, Schema } from "effect"
import { Task } from "@durable-effect/task"
import { createTasks } from "@durable-effect/task/cloudflare"

const CounterState = Schema.Struct({
  count: Schema.Number,
})

const StartEvent = Schema.Struct({
  _tag: Schema.Literal("Start"),
})

const counter = Task.define({
  state: CounterState,
  event: StartEvent,

  onEvent: (ctx, _event) =>
    Effect.gen(function* () {
      yield* ctx.save({ count: 0 })
      yield* ctx.scheduleIn("2 seconds")
    }),

  onAlarm: (ctx) =>
    Effect.gen(function* () {
      const current = yield* ctx.recall()
      const count = (current?.count ?? 0) + 1
      yield* ctx.save({ count })

      if (count >= 10) {
        yield* ctx.purge()
      }

      yield* ctx.scheduleIn("2 seconds")
    }),

  onError: (ctx, _error) =>
    Effect.gen(function* () {
      yield* ctx.save({ count: -1 })
    }),
})

export const { TasksDO, tasks } = createTasks({ counter })

Build docs developers (and LLMs) love