Typed Cancellation

The Course
•
6 min read

AbortSignal is a runtime capability that tells cooperative work when to stop. An annotation can require a signal parameter, but only runtime checks and listener cleanup implement cancellation. Review JavaScript intermediate’s pending cancellation example if this behavior is unfamiliar.

Some APIs use rejection for cancellation. Others return a distinct cancelled variant. The following Outcome<T> chooses the latter for its own adapter; the underlying work can still throw or reject. Decide the public policy before writing catch logic.

Check a pre-aborted signal before invoking work, then pass the signal into the callback so it can cooperate. Check again when the awaited work settles, on both success and failure. This adapter gives cancellation priority if the signal is aborted at either settlement boundary; a different API could preserve the original outcome instead. It still waits for the callback to settle.

Do not promise that a callback ignoring the signal is interrupted. A typed function parameter is a caller contract, not a scheduler. The final catalogue project separately demonstrates stale-result rejection, without claiming that it cancels transport work.

Predict, run, and explain

Write the output and trace the important state changes before checking it. Then run the program. Use Check types first; execution is allowed only when the real compiler accepts the source.

Predict, then runTypeScript
type Outcome<T> =
  | {
      tag: "ok";

      value: T;
    }
  | { tag: "cancelled" };

async function run<T>(
  work: (signal: AbortSignal) => Promise<T>,
  signal: AbortSignal
): Promise<Outcome<T>> {
  if (signal.aborted) {
    return { tag: "cancelled" };
  }

  const value = await work(signal);

  if (signal.aborted) {
    return { tag: "cancelled" };
  }

  return { tag: "ok", value };
}

// This introductory version assumes work succeeds; add catch in the exercise.
const controller = new AbortController();

controller.abort();

const result = await run(async () => 25, controller.signal);

console.log(result.tag);

Enable JavaScript to edit and type-check this example.

Check your prediction
cancelled

Cancellation policy includes the settlement boundary

Passing a signal lets a callback cooperate; it does not force it to stop. A public adapter must also decide whether to publish a value that fulfills after the caller aborted. This lesson gives cancellation priority at the settlement boundary while still waiting for work to settle.

Use controlled promises to test that choice. Pre-abort should prevent callback starts. Mid-flight abort should not claim the underlying callback already stopped, and it should not accidentally expose its late success if the policy says cancellation wins.

Cancellation policy includes the settlement boundary: traceTypeScript
let release: (value: number) => void = () => {};
const pending = new Promise<number>(resolve => {
  release = resolve;
});
const controller = new AbortController();

async function finish(): Promise<string> {
  const value = await pending;

  return controller.signal.aborted ? "cancelled" : String(value);
}

const work = finish();

controller.abort();
release(25);
console.log(await work);

Enable JavaScript to edit and type-check this example.

Check your prediction
cancelled

The callback completed with a real value, but the adapter deliberately suppressed it after checking ownership. This is observable policy, not proof the work was interrupted.

Cancellation policy includes the settlement boundary: contrastTypeScript
const controller = new AbortController();

controller.abort("stop");

let starts = 0;

async function work(signal: AbortSignal): Promise<number> {
  signal.throwIfAborted();
  starts++;
  return 1;
}

try {
  await work(controller.signal);
} catch (reason) {
  console.log(String(reason));
}

console.log(starts);

Enable JavaScript to edit and type-check this example.

Check your prediction
stop
0

The check occurs before the side effect. Moving it below starts++ would preserve the final cancellation result while violating the no-start contract.

Try the rule on new inputs

Test pre-abort, abort before fulfillment, abort before rejection and abort after the adapter has returned. Explain which outcomes can still change and why already completed side effects cannot be undone by a signal.

Your turn

Exercise

Typed Cancellation: practise

Implement Outcome<T> with ok, cancelled and error variants. Write run(work,signal), passing the signal to work and applying the cancellation-priority rule on success and failure. Convert an ordinary thrown Error to an error message. Test pre-abort starts no callback, normal success, abort followed by success, cooperative abort rejection, and an ordinary error. Print cancelled 0, 25, cancelled, cancelled and offline.

Your workspaceTypeScript
// Catch unknown values safely, and state which condition chooses cancellation.

Enable JavaScript to edit and type-check this example.

Hint 1: the idea

Checking before invocation prevents work from starting for an already-aborted request.

Hint 2: a first step

Return a cancelled variant before calling work. After awaiting work(signal), inspect signal.aborted again; do the same in catch before formatting an error.

Show one solution
One possible solutionTypeScript
type Outcome<T> =
  | {
      tag: "ok";

      value: T;
    }
  | { tag: "cancelled" }
  | {
      tag: "error";

      message: string;
    };

async function run<T>(
  work: (signal: AbortSignal) => Promise<T>,
  signal: AbortSignal
): Promise<Outcome<T>> {
  if (signal.aborted) {
    return { tag: "cancelled" };
  }

  try {
    const value = await work(signal);

    return signal.aborted ? { tag: "cancelled" } : { tag: "ok", value };
  } catch (error) {
    if (signal.aborted) {
      return { tag: "cancelled" };
    }

    return {
      tag: "error",
      message: error instanceof Error ? error.message : "Failed"
    };
  }
}

let starts = 0;
const stopped = new AbortController();

stopped.abort();

const a = await run(async () => {
  starts++;
  return 1;
}, stopped.signal);

console.log(a.tag, starts);

const b = await run(async () => 25, new AbortController().signal);

console.log(b.tag === "ok" ? b.value : b.tag);

const ignored = new AbortController();
const c = await run(async () => {
  ignored.abort();
  return 99;
}, ignored.signal);

console.log(c.tag);

const cooperative = new AbortController();
const d = await run(async signal => {
  cooperative.abort();
  signal.throwIfAborted();
  return 99;
}, cooperative.signal);

console.log(d.tag);

const e = await run(async () => {
  throw new Error("offline");
}, new AbortController().signal);

console.log(e.tag === "error" ? e.message : e.tag);

Enable JavaScript to edit and type-check this example.

cancelled 0
25
cancelled
cancelled
offline

Explain why the implementation meets the stated contract. Which part is enforced by the language or checker, and which part needs behavioral evidence?

Change it and test it

Hold a callback on a deferred promise, abort while it is pending, then release it. Confirm the adapter waits for settlement and returns cancelled rather than publishing the value. Repeat with a rejection. A callback can ignore its signal parameter, so this policy prevents publishing its late result but cannot undo its side effects or force it to stop. An abort after run has already returned cannot retroactively change its result.

Was this article helpful?

Your response is saved on this device.