Skip to content

Tasks

A task is an object with a name, an execute function, and optionally a compensate function. That's it.

ts
{
  name: "charge",
  execute: async ({ input, results, context }) => ({ chargeId: "ch_1" }),
  compensate: async ({ result, context }) => context.payments.refund(result.chargeId),
}

The name is not decoration: it is the key the task's result lands under in results, and the label it reports itself by in hooks and in executedTasks. Because it is the result key, it must be unique within a saga; build() throws on a duplicate rather than letting the second silently overwrite the first.

.task(): one step

ts
defineSaga<Input, Ctx>("checkout")
  .task({
    name: "validate",
    execute: async ({ input }) => ({ total: sum(input.items) }),
  })
  .task({
    name: "charge",
    // results.validate is typed here, because validate ran first
    execute: async ({ results, context }) => context.payments.charge(results.validate.total),
  });

Each .task() widens the saga's result type by { [name]: whatever execute returned }.

.mapTask(): one step per item, in order

Runs execute once per item from iterator, sequentially. The results collect into an array under the task's name.

ts
.mapTask({
  name: "splitShipments",
  iterator: ({ input }) => input.items,
  execute: async ({ value, index, array, context }) =>
    context.warehouse.ship(value.sku),
})
// results.splitShipments is Shipment[]

Alongside the usual three, mapped callbacks get value, index and array.

Iterations are named individually (splitShipments-0, splitShipments-1), so each one can be compensated on its own and shows up separately in hooks.

Naming iterations with key

The default naming interpolates primitive iterands over a list of SKUs the iterations are splitShipments-kbd-01 and so on, and falls back to the index for anything else, because every object stringifies to [object Object]. input.items holds objects, so supply key to name those yourself:

ts
.mapTask({
  name: "splitShipments",
  iterator: ({ input }) => input.items,
  key: ({ value }) => value.sku,        // "splitShipments-kbd-01"
  execute: async ({ value }) => ship(value),
})

key is yours to keep unique. Two items returning the same key produce two identically named iterations.

Reading earlier iterations

Inside a sequential mapped task, results[taskName] holds what the iterations before this one produced. Useful for running totals; not available in the parallel variant, where there is no "before".

Builders are immutable

Every builder method returns a new builder rather than changing the one it was called on, so a shared prefix stays shared:

ts
const base = defineSaga<OrderInput, Services>("checkout").task(validate);

const card = base.task(chargeCard).build(services);
const credit = base.task(useCredit).build(services);
// card runs validate + chargeCard; credit runs validate + useCredit

The flip side: the return value is the builder. Calling builder.task(t) and throwing the result away adds nothing.

Reusable definitions

defineTask and defineMapTask build a task you can share across sagas. They validate the config and freeze a copy of it, so a definition cannot be mutated by whoever uses it.

ts
import { defineTask } from "minisagas";

const reserve = defineTask<OrderInput, Services>()({
  name: "reserve",
  options: { retry: { attempts: 3, delay: 100 } },
  execute: async ({ input, context }) => context.warehouse.hold(input.orderId),
  compensate: async ({ result, context }) => context.warehouse.release(result.holdId),
});

defineSaga<OrderInput, Services>("checkout").task(reserve);

Why the double call

defineTask<...>()({ ... }) looks odd. TypeScript infers type arguments all-or-nothing: name the first one and you must name them all. TName and the result type are inferable from the config object; TInput and TContext appear only in callback parameters and have nothing to infer from.

Splitting into two calls lets you name what cannot be inferred and infer the rest.

Declaring what a task needs

The third type parameter says what the task expects to already be in results. Any saga that has produced at least that shape by that point accepts it:

ts
const notify = defineMapTask<OrderInput, Services, { splitShipments: Shipment[] }>()({
  name: "notify",
  iterator: ({ context }) => context.regions,
  execute: async ({ value, results }) => alert(value, results.splitShipments.length),
});

Put notify in a saga that has not run splitShipments and it will not compile.

You don't have to

Anywhere a definition is accepted, a plain config object works, and the builder runs it through the same validation and freezing. Use defineTask when a task is shared between sagas or tested on its own; inline the object when it isn't.

MIT Licensed