{"protocol":"clank-doc/1","frameworkVersion":"0.19.5","slug":"typed-tasks","title":"Typed tasks, failures, and services","description":"Clank Task is an opt in , dependency free computation runtime for operations that need stronger guarantees than an ordinary promise. It makes success, expected failure, and required services visible in one TypeScript type:","group":{"id":"framework","title":"Framework"},"url":"https://docs.clank.run/docs/typed-tasks","source":"docs/task.md","headings":["Start with a typed operation","Outcome model","Generator composition","Typed services and layers","Resource safety","Retry and schedules","Structured concurrency","Cancellation and timeout","Observability","Deterministic time","Use Task at framework boundaries","Choosing the right primitive"],"tableOfContents":[{"id":"start-with-a-typed-operation","title":"Start with a typed operation","level":2},{"id":"outcome-model","title":"Outcome model","level":2},{"id":"generator-composition","title":"Generator composition","level":2},{"id":"typed-services-and-layers","title":"Typed services and layers","level":2},{"id":"resource-safety","title":"Resource safety","level":2},{"id":"retry-and-schedules","title":"Retry and schedules","level":2},{"id":"structured-concurrency","title":"Structured concurrency","level":2},{"id":"cancellation-and-timeout","title":"Cancellation and timeout","level":2},{"id":"observability","title":"Observability","level":2},{"id":"deterministic-time","title":"Deterministic time","level":2},{"id":"use-task-at-framework-boundaries","title":"Use Task at framework boundaries","level":2},{"id":"choosing-the-right-primitive","title":"Choosing the right primitive","level":2}],"markdown":"# Typed tasks, failures, and services\n\nClank Task is an **opt-in**, dependency-free computation runtime for operations that need stronger\nguarantees than an ordinary promise. It makes success, expected failure, and required services\nvisible in one TypeScript type:\n\n```ts\nTask<Success, Failure, Requirements>\n```\n\nNothing else in Clank requires Task. Components, routes, queries, mutations, jobs, and MCP actions\ncontinue to accept ordinary functions and promises. Adopt Task at a risky boundary, in one service,\nor across an application without changing the framework's basic programming model.\n\nImport it from the package root or its focused entry point:\n\n```ts\nimport { Task, Layer, Schedule, service } from \"@clank.run/framework/task\";\n```\n\n## Start with a typed operation\n\n`Task` is lazy: constructing a task does not execute it. `runPromise` executes it and returns its\nsuccess value. `runExit` preserves every outcome as data.\n\n```ts\ntype LoadError =\n  | { code: \"not-found\"; id: string }\n  | { code: \"unavailable\"; message: string };\n\nconst loadTodo = (id: string): Task<Todo, LoadError> =>\n  Task.tryPromise({\n    try: (signal) => fetch(`/api/todos/${id}`, { signal }).then(async (response) => {\n      if (response.status === 404) throw { code: \"not-found\", id };\n      if (!response.ok) throw new Error(`HTTP ${response.status}`);\n      return await response.json() as Todo;\n    }),\n    catch: (error): LoadError =>\n      typeof error === \"object\" && error !== null && \"code\" in error\n        ? error as LoadError\n        : { code: \"unavailable\", message: \"Todo service is unavailable.\" },\n  });\n\nconst title = await Task.runPromise(\n  loadTodo(\"todo_123\")\n    .map((todo) => todo.title)\n    .catchAll((error) => Task.succeed(\n      error.code === \"not-found\" ? \"Missing todo\" : \"Try again\",\n    )),\n);\n```\n\nUse `Task.fromPromise(operation)` when `unknown` is an honest failure type. Use\n`Task.tryPromise({ try, catch })` when the boundary can translate unknown rejection values into a\nstable application error. Both operations receive the runtime `AbortSignal`.\n\n## Outcome model\n\nA Task finishes with an `Exit`:\n\n```ts\ntype Exit<A, E> =\n  | { _tag: \"Success\"; value: A }\n  | { _tag: \"Failure\"; cause: Cause<E> };\n```\n\n`Cause<E>` keeps three important conditions separate:\n\n- `Failure` is an expected, typed business or boundary failure created with `Task.fail()`.\n- `Defect` is an unexpected thrown exception or broken runtime invariant.\n- `Interrupted` means the task was canceled through structured concurrency or an external signal.\n\nSequential and parallel causes retain multiple failures when both an operation and cleanup fail.\n`catchAll` handles only expected failures. It cannot accidentally swallow a defect or\ninterruption. Use `catchCause` only at an intentional diagnostic or process boundary.\n\n```ts\nconst exit = await Task.runExit(loadTodo(\"todo_123\"));\n\nif (Exit.isFailure(exit)) {\n  logger.error(\"Todo task failed\", { cause: Cause.pretty(exit.cause) });\n}\n```\n\n`runPromise` rejects with `TaskExecutionError`, whose `.cause` contains the same structured value.\nDo not parse its message or expose raw defects to a client.\n\n## Generator composition\n\nEvery Task can be yielded directly, making sequential programs readable without hiding their\nfailure or requirement channels:\n\n```ts\nconst program = Task.gen(function* () {\n  const account = yield* loadAccount(\"acct_123\");\n  const todos = yield* loadTodos(account.id);\n  return { account, todos };\n});\n```\n\nThe method forms—`map`, `flatMap`, `tap`, `as`, `mapError`, `catchAll`, `catchCause`, and\n`ensuring`—are equally supported. Use whichever form makes data flow easiest for a human or agent\nto inspect.\n\n## Typed services and layers\n\nA service token is a nominal runtime key and a TypeScript requirement. Reading it changes the\nthird channel of the Task type:\n\n```ts\ninterface Mailer {\n  send(input: { to: string; subject: string }, signal: AbortSignal): Promise<void>;\n}\n\nconst Mailer = service<Mailer>(\"Mailer\");\n\nconst sendWelcome = (email: string): Task<void, \"delivery-failed\", typeof Mailer> =>\n  Task.service(Mailer).flatMap((mailer) => Task.tryPromise({\n    try: (signal) => mailer.send({ to: email, subject: \"Welcome\" }, signal),\n    catch: () => \"delivery-failed\" as const,\n  }));\n```\n\nA `Layer` satisfies one or more requirements. Value layers have no lifecycle:\n\n```ts\nconst testMailer = Layer.succeed(Mailer, {\n  async send() {},\n});\n\nawait Task.runPromise(sendWelcome(\"ada@example.com\"), { layer: testMailer });\n```\n\nResource layers acquire once per runtime scope, are memoized within that scope, and release in\nreverse acquisition order:\n\n```ts\nconst mailerLayer = Layer.effect(\n  Mailer,\n  Task.tryPromise({\n    try: () => openMailer(),\n    catch: () => \"mailer-start-failed\" as const,\n  }),\n  (mailer) => Task.fromPromise(() => mailer.close()).catchAll(() => Task.succeed(undefined)),\n);\n```\n\nCompose independent services with `left.merge(right)`. Duplicate providers fail as defects instead\nof silently shadowing each other. A layer can itself require services, and `.provide(layer)` can\nscope a layer to one subprogram.\n\nClank's existing `ServiceRegistry` remains the deployment/service-driver catalog. Task services\nsolve a different problem: typed, per-program dependency requirements. A Task service may wrap a\nregistry value when both contracts are useful.\n\n## Resource safety\n\n`Task.acquireRelease()` registers cleanup immediately after successful acquisition. Cleanup runs\nexactly once, in LIFO order, on success, typed failure, defect, interruption, or timeout.\n\n```ts\nconst file = Task.acquireRelease(\n  Task.tryPromise({\n    try: () => open(\"report.csv\", \"r\"),\n    catch: () => \"open-failed\" as const,\n  }),\n  (handle) => Task.fromPromise(() => handle.close())\n    .catchAll(() => Task.succeed(undefined)),\n);\n\nconst read = Task.scoped(\n  file.flatMap((handle) => Task.tryPromise({\n    try: (signal) => readHandle(handle, signal),\n    catch: () => \"read-failed\" as const,\n  })),\n);\n```\n\nUse `Task.addFinalizer()` for a cleanup action that has no acquired value. Finalizers are\nuninterruptible, but they should still be bounded internally: cleanup that never settles prevents\nthe enclosing scope from closing.\n\n## Retry and schedules\n\nSchedules are reusable values. A schedule receives the typed error, zero-based retry attempt,\nelapsed time, and the runtime random source.\n\n```ts\nconst transient = Schedule.exponential(250, {\n  factor: 2,\n  maxDelay: 30_000,\n  jitter: 0.2,\n})\n  .intersect(Schedule.recurs(5))\n  .while((error: LoadError) => error.code === \"unavailable\");\n\nconst resilientLoad = loadTodo(\"todo_123\")\n  .retry(transient)\n  .timeout(45_000);\n```\n\n- `Schedule.recurs(n)` permits exactly `n` retries after the initial attempt.\n- `Schedule.spaced(ms)` uses a fixed delay.\n- `Schedule.exponential(base, options)` provides bounded exponential backoff and optional jitter.\n- `intersect` requires both schedules to continue and uses the longer delay.\n- `union` continues while either schedule continues and uses the shorter available delay.\n- `while` retries only matching expected failures.\n- `mapDelay` transforms and revalidates each delay.\n\nRetry applies only to typed `Failure`. Defects and interruption are never retried. Durations are\nbounded to JavaScript's safe timer range, and injected random sources are validated.\n\nUse durable Clank jobs instead when work must survive a process restart. Task retry is an\nin-process execution policy; jobs provide persisted attempts, leases, idempotency, and at-least-once\ndelivery. The two compose naturally inside a job handler.\n\n## Structured concurrency\n\n`Task.all` runs child scopes concurrently, retains input ordering, limits concurrency, and\ninterrupts siblings after the first failure:\n\n```ts\nconst [account, todos, limits] = await Task.runPromise(Task.all(\n  [loadAccount(id), loadTodos(id), loadLimits(id)],\n  { concurrency: 3 },\n));\n```\n\n`Task.race(left, right)` returns the first completed `Exit`, interrupts the loser, waits for both\nchild scopes to close, and then returns. `Task.fork(task)` creates a `Fiber` with `join()`, `exit`,\n`interrupt()`, and `done`. A fiber is attached to its parent scope, so returning without joining it\ninterrupts the child instead of leaking background work.\n\nFor long-lived, restart-safe parallel work, prefer workflow graphs. Fibers are intentionally\nprocess-local.\n\n## Cancellation and timeout\n\nSupply an external signal at the runtime boundary:\n\n```ts\nconst controller = new AbortController();\n\nconst running = Task.runExit(program, { signal: controller.signal });\ncontroller.abort(\"request disconnected\");\n\nconst exit = await running;\n```\n\nPromise integrations must pass the received signal to the underlying API. JavaScript cannot\nforcibly stop an arbitrary promise that ignores cancellation. Clank stops awaiting that promise,\ncloses child scopes, and suppresses late results, but the external operation needs its own signal\nsupport to stop consuming resources.\n\n`task.timeout(milliseconds)` adds `TimeoutError` to the typed failure channel. It interrupts the\nchild, waits for cleanup, and only then returns the timeout failure.\n\n## Observability\n\n`withSpan(name, attributes?)` delegates to the runtime tracer. Clank's existing observability\ntracer implements the compatible shape:\n\n```ts\nconst observability = createObservability({ serviceName: \"todos\" });\n\nawait Task.runPromise(\n  loadTodo(id).withSpan(\"todo.load\", { \"todo.source\": \"sqlite\" }),\n  { tracer: observability.tracer },\n);\n```\n\nUse bounded, low-cardinality attributes. Do not attach passwords, tokens, cookies, raw request\nbodies, email addresses, or unbounded user-controlled values.\n\n## Deterministic time\n\n`TestClock` makes sleep, retry, race, and timeout deterministic without waiting for wall time:\n\n```ts\nconst clock = new TestClock(1_000);\nconst running = Task.runPromise(Task.sleep(5_000).as(\"done\"), { clock });\n\nawait Promise.resolve(); // allow the task to register its timer\nclock.advance(5_000);\n\nassert.equal(await running, \"done\");\nassert.equal(clock.pending, 0);\n```\n\n`set()` cannot move backwards. `runAll()` resolves timers currently registered at the furthest\ndeadline; advance again after asynchronous continuations schedule more work.\n\n## Use Task at framework boundaries\n\nRoutes, mutations, jobs, and MCP actions can run a Task at their existing async boundary:\n\n```ts\nconst backend = defineBackend({ schema }).functions(({ mutation }) => ({\n  todos: {\n    remind: mutation({\n      args: { id: s.id(\"todos\") },\n      handler: async (_context, { id }) => await Task.runPromise(\n        remindTodo(id),\n        {\n          layer: applicationLayer,\n          tracer: observability.tracer,\n        },\n      ),\n    }),\n  },\n}));\n```\n\nTranslate `TaskExecutionError.cause` into the boundary's stable public error type. Never serialize\nan unexpected defect directly to a browser or agent. Existing Clank request limits, authorization,\nownership checks, transactions, and MCP confirmation policies remain authoritative; Task does not\nbypass any framework security boundary.\n\n## Choosing the right primitive\n\nUse an ordinary promise when an operation is short, has no managed resources, and `unknown` is an\nadequate failure contract. Use Task for typed boundary errors, multi-step resource ownership,\nservice injection, process-local parallelism, retry, or deterministic timing. Use a durable job,\nworkflow, or durable object when execution must survive restarts, coordinate across processes, or\nretain an auditable history.\n\nThis division keeps simple Clank applications simple while giving high-risk paths stronger,\nmachine-readable semantics.\n"}