Files
atelier/languages/ts-async.md
T
Jon Chery 4e433158cd docs(P03): complete language-derived extension — v0.4
---ci---
project: atelier
phase: 3
milestone: v0.4
status: complete
phase_role: execution
phase_tag: v0.3.3
requirements:
  covered: [ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105]
  partial: []
---/ci---
2026-08-05 16:07:21 +00:00

6.5 KiB

TypeScript Async — Derived Application

Applies Atelier's domain principles to TypeScript async specifically. Derives from domains/ docs; introduces no new P-rules (D-063). See languages/typescript.md for the language first-principles stub.

Promises and AbortSignal (Concurrency P7 Cancellation Support, C1 Correctness)

  • Every async function accepts an optional AbortSignal: cancellation is a first-class parameter, not a side channel. The signal propagates to fetch, setTimeout, and downstream awaits.
  • AbortController is the producer side; AbortSignal is the consumer side: a function takes a signal (read-only), the caller owns the controller and decides when to abort.
  • Abort propagates as a rejected Promise: fetch rejects with AbortError; downstream code sees the rejection, not a silent no-op. This preserves errors/P5 (recoverable when possible) — the caller can distinguish cancellation from a real failure.
  • Applies concurrency/P7: no async operation runs without a path to cancel it. A long-running await with no signal is a hung request.
  • Never swallow AbortError: re-throw or handle distinctly; cancellation is the caller's intent, not an error to log.
async function fetchUser(id: UserId, signal?: AbortSignal): Promise<User> {
  const ctrl = new AbortController();
  signal?.addEventListener('abort', () => ctrl.abort());
  const res = await fetch(`/users/${id}`, { signal: ctrl.signal });
  if (!res.ok) throw new HttpError(res.status);
  return res.json() as Promise<User>;
}

// caller controls cancellation
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 5000);
try {
  const u = await fetchUser(id, ctrl.signal);
} finally {
  clearTimeout(timer);
}

async/await Discipline (Concurrency P8 Timeout Discipline, C2 Clarity)

  • await is the only async primitive in application code: no .then chains, no callback pyramids. async/await reads top-to-bottom (Clarity C2).
  • Never await in a hot loop without batching: sequential await in a for loop is O(n) latency. Use Promise.all for parallelism; for await...of only for genuine streams.
  • Promise.race for a timeout: every external await has a deadline. Promise.race([op, timeout]) rejects when the deadline passes.
  • return vs return await: inside try/finally, return await runs the finally; bare return of a Promise defers the finally to the microtask. Prefer return await when cleanup must run.
  • Applies concurrency/P8: a bare await with no timeout is an unbounded wait. External calls (network, disk) always race against a deadline.
async function fetchWithTimeout(url: string, ms = 5000, signal?: AbortSignal): Promise<Response> {
  const ctrl = new AbortController();
  signal?.addEventListener('abort', () => ctrl.abort());
  const timer = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new TimeoutError(ms)), ms)
  );
  try {
    return await Promise.race([fetch(url, { signal: ctrl.signal }), timer]);
  } finally {
    clearTimeout(timer); // cleanup runs on success and on race-loss
  }
}

Error Handling in Async (Errors P5 Recoverable When Possible, Errors P1 Errors are Data)

  • Catch unknown, narrow with a type guard: catch (e: unknown) — TS does not infer the error type. instanceof or a discriminator narrows it.
  • Retry with backoff for transient failures: network blips are recoverable (Errors P5). Exponential backoff with jitter, capped retry count, and an AbortSignal-aware setTimeout.
  • No retry for non-idempotent operations: a POST that creates a resource is not safely retryable without an idempotency key (applies api/P6 Idempotency).
  • Typed errors over Error subclasses: a discriminated union AppError = Network | Timeout | Cancelled carries context (Errors P4 Preserve Context) without instanceof chains.
async function fetchRetry(url: string, attempts = 3, signal?: AbortSignal): Promise<Response> {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fetchWithTimeout(url, 5000, signal);
    } catch (e: unknown) {
      if (e instanceof AbortError) throw e;          // do not retry cancellation
      if (e instanceof TimeoutError && i < attempts - 1) {
        await sleep(jitter(i), signal);             // backoff before retry
        continue;
      }
      throw e;
    }
  }
  throw new Error('unreachable');
}

Cancellation Propagation (Concurrency P7 Cancellation Support, Concurrency P9 Bounded Queues)

  • One signal, many consumers: pass the same AbortSignal to every async call in a request. Aborting once cancels the whole tree.
  • Bounded concurrency with a semaphore: a Semaphore(N) wrapping Promise.all caps in-flight requests (Concurrency P9 — bounded queues). Unbounded Promise.all on a 10k-item array exhausts file descriptors.
  • Cancellation is cooperative, not preemptive: a long synchronous block inside an async function ignores the signal. Yield with await Promise.resolve() periodically in CPU-bound loops, or move to a worker.
  • Applies messaging/delivery-semantics: a cancelable async operation is an at-most-once delivery — the caller may stop listening, the result may or may not arrive. Retry-on-cancel is at-least-once; the caller must declare which.
async function mapBounded<T, U>(items: readonly T[], fn: (t: T, s: AbortSignal) => Promise<U>, limit = 8, signal?: AbortSignal): Promise<U[]> {
  const ctrl = new AbortController();
  signal?.addEventListener('abort', () => ctrl.abort());
  const results: U[] = new Array(items.length);
  let next = 0;
  const workers = Array.from({ length: limit }, async () => {
    while (true) {
      const i = next++;
      if (i >= items.length) break;
      if (ctrl.signal.aborted) throw new AbortError();
      results[i] = await fn(items[i], ctrl.signal);
    }
  });
  await Promise.all(workers);
  return results;
}

Cross-References

  • domains/concurrency/patterns.md — the cancellation/timeout/semaphore patterns applied here.
  • domains/concurrency/first-principles.md — Concurrency P7 Cancellation Support, P8 Timeout Discipline, P9 Bounded Queues.
  • domains/messaging/delivery-semantics.md — at-most-once vs at-least-once framing for async retry/cancel (IDEATE-40).
  • domains/errors/patterns.md — typed async errors and retry-with-backoff.
  • languages/ts-types.mdResult<T, E> and discriminated AppError used in async error handling.
  • languages/ts-tooling.mdno-floating-promises lint rule that enforces these awaits.