FullStack Course LogoFullStack Course
Module: TypeScript
TypeScript·173·12 MIN READ

173: Async TypeScript, Promises, Errors, Result Types, and Cancellation

TOPICS COVERED: Async TypeScript, Promises, Errors, Result Types, and Cancellation

Learning outcomes

By the end of this lesson, you can:

  • explain and apply promise typing in a realistic implementation;
  • explain and apply caught errors in a realistic implementation;
  • explain and apply error classes in a realistic implementation;
  • explain and apply result types in a realistic implementation;
  • explain and apply abortsignal in a realistic implementation.

Prerequisites and retrieval

This lesson assumes the earlier 01–06 foundation and the preceding lessons in this module. Before you read, retrieve one concrete example from an earlier project where one of these concerns appeared. Perhaps an API call returned later than the current function, a catch block received a value you could not safely inspect, or a user action needed to cancel work that was already in progress. The point is not to memorize a list of terms. It is to make a defensible design decision in a strict TypeScript codebase, using the compiler to model domain invariants without pretending that static types replace runtime validation.

Terminology

  • Promise typing: An async function returns a Promise whose resolved value has a particular type. The type describes the eventual result, not the fact that the work has already completed.
  • Caught errors: With useUnknownInCatchVariables, the value caught by a catch clause is unknown. It must be narrowed before code reads properties such as message or code.
  • Error classes: Custom error classes can carry stable machine-readable codes and contextual data while preserving the normal Error stack-trace behavior.
  • Result types: A discriminated Result<T,E> can make an expected failure explicit, particularly when callers are expected to branch on success versus failure rather than use exceptions for ordinary control flow.
  • AbortSignal: Cancellation is a runtime protocol. An AbortSignal communicates that work should stop; its type alone cannot force an underlying operation to honor that request.
  • Concurrency typing: Promise combinators, including Promise.all and Promise.allSettled, describe how several asynchronous operations complete and what relationship exists between their results. Treat this as a precise engineering concept, not merely vocabulary.

Mental model

Treat Async TypeScript, Promises, Errors, Result Types, and Cancellation as a design problem with observable inputs, outputs, invariants, and failure modes. TypeScript can model asynchronous control flow precisely, but it cannot guarantee that a remote dependency will resolve, that its response has the shape you expect, or that a thrown value is an Error. A strong implementation makes its assumptions visible, narrows uncertainty at boundaries, and leaves enough evidence—tests, types, constraints, metrics, or diagrams—to show why the design is safe.

For example, a promise type can tell you that a function resolves to a Task, but it does not validate JSON received from a server. Likewise, an AbortSignal can be passed through every layer and still fail to stop work if a provider ignores the signal. These are runtime boundaries, so the design has to account for them explicitly.

A useful interview and production sequence is:

text
requirement -> constraints -> model -> implementation -> failure analysis -> verification

Do not jump from a requirement directly to a library call. First state what must remain true. Then choose the mechanism that enforces it, and finally decide how you will observe failures when the mechanism or its dependency does not behave as expected.

Deep dive

1. Promise typing

When a function performs asynchronous work, its caller receives a promise rather than the resolved value immediately. An async function returns a Promise of its resolved value, so its signature should communicate that useful value type. Preserve the generic type at adapter boundaries and avoid allowing Promise<any> to erase information that callers need.

The promise describes the eventual success value and the fact that completion is deferred. It does not, by itself, describe every way the operation can fail. Rejection behavior still needs a deliberate convention, and data coming from an external system still needs runtime validation.

Decision rule: Use promise typing deliberately when it makes the contract or invariant easier to prove. If it only reduces typing while hiding an assumption, prefer the more explicit design.

2. Caught errors

This is where people usually get confused: a catch block does not prove that the thrown value came from new Error(). JavaScript permits code to throw strings, plain objects, numbers, or values produced by third-party libraries. With useUnknownInCatchVariables, a caught value is unknown, which forces the handler to narrow it before reading message, code, or any other property.

That extra narrowing is useful at a boundary. It makes the handler acknowledge what it has actually established instead of silently trusting a convention that another module may not follow. A safe handler can check error instanceof Error, use a project-specific type guard, or map the unknown value into a known domain error.

Decision rule: Use caught errors deliberately when it makes the contract or invariant easier to prove. If it only reduces typing while hiding an assumption, prefer the more explicit design.

3. Error classes

An ordinary error message is useful to a person but often unstable for program logic. Custom error classes can add a stable machine-readable code and contextual fields while retaining the normal stack trace. That gives callers and logging code something more reliable than parsing a message string.

Keep the boundary between internal diagnostics and client-facing output clear. Do not serialize internal stacks, credentials, tokens, or other secrets directly to clients. Map an internal error to the public response shape at the owning boundary, and include only the context that the consumer is allowed to see.

Decision rule: Use error classes deliberately when it makes the contract or invariant easier to prove. If it only reduces typing while hiding an assumption, prefer the more explicit design.

4. Result types

A function that expects an ordinary “not found” outcome has two broad choices: reject a promise and make the caller distinguish that rejection from an unexpected failure, or return a value whose type explicitly contains success and expected failure. A discriminated Result<T,E> is useful in the second case. The caller branches on a discriminant such as ok, and TypeScript can narrow the corresponding value or error in each branch.

Result types do not make failures disappear, and they are not a universal replacement for exceptions. Exceptions remain appropriate for unexpected failures and for failures handled by a framework or process boundary. The useful distinction is whether the failure is an expected part of the function's contract and should be handled as data, or whether it represents an exceptional condition that should propagate through an error path. Pick one convention deliberately for each boundary.

Decision rule: Use result types deliberately when it makes the contract or invariant easier to prove. If it only reduces typing while hiding an assumption, prefer the more explicit design.

5. AbortSignal

Cancellation is a runtime protocol, not just a type annotation. Thread an AbortSignal through fetch, query, and service layers that support it. The signal gives those operations a shared way to observe cancellation, but each underlying operation must actually check or honor it.

Decide what cancellation means to the caller. It may be an error, an expected outcome, or a cleanup event. That choice affects logging, retries, UI state, and whether cancellation should be returned in a Result or allowed to reject the promise. A timeout is also a form of cancellation strategy, but it should not be confused with validation or with a guarantee that the remote server stopped its own work.

Decision rule: Use abortsignal deliberately when it makes the contract or invariant easier to prove. If it only reduces typing while hiding an assumption, prefer the more explicit design.

6. Concurrency typing

When several asynchronous operations must run together, the combinator determines what the caller can safely conclude. Promise.all preserves tuple result relationships for known tuples and rejects when one of the participating promises rejects. It is a good fit when the operation needs all results to continue.

Promise.allSettled exposes fulfilled and rejected discriminants for every operation. That makes it useful when partial success is part of the requirement and the caller must inspect each outcome rather than fail the whole group at the first rejection. The choice is therefore about failure semantics as much as about typing.

Decision rule: Use concurrency typing deliberately when it makes the contract or invariant easier to prove. If it only reduces typing while hiding an assumption, prefer the more explicit design.

Worked example

Consider a strict TypeScript codebase where the compiler models domain invariants without pretending that static types replace runtime validation. Start by writing the requirement in one sentence. Then list the input and output contracts and identify which concept above owns each failure mode.

The important design move is separation. Parsing or validation belongs at the boundary; domain rules belong in the domain or service layer; persistence rules belong in the database or repository; presentation rules belong in the client. Mixing these concerns can make a happy-path demo look shorter, but it makes edge cases and ownership much harder to reason about.

Here is a small boundary parser. unknown is intentional: callers have not yet proved what they received. The branded type is useful inside this codebase after the runtime check, but the assertion on the return line does not validate anything by itself.

ts
type TaskId = string & { readonly __brand: 'TaskId' };

function parseTaskId(value: unknown): TaskId {
  if (typeof value !== 'string' || value.length === 0) {
    throw new TypeError('Invalid task id');
  }
  return value as TaskId;
}

Walk through the example with at least four cases: the normal path; an empty or missing value; a duplicate, retry, or concurrent path where that concern applies; and a dependency failure. For each case, state which layer detects the problem and what the caller observes. For example, this parser owns the empty-value check, but it cannot detect a duplicate task in a database or determine whether a downstream provider is unavailable. That level of ownership analysis is what a senior code review or technical interview expects.

Production perspective

Production correctness is broader than “the code works on my machine.” Ask how the design behaves during deploys, retries, partial failure, stale clients, concurrent requests, malformed data, schema changes, and high-cardinality workloads. Prefer explicit contracts, bounded resource usage, structured errors, and measurable behavior. Optimize only after you can identify the bottleneck or risk with evidence.

When the topic involves an external dependency, define both a timeout and a cancellation strategy. When it involves persistence, define transaction and consistency expectations. When it involves user-visible state, define loading, empty, error, stale, and success states. When it involves security, assume the client can be modified and all network input is untrusted. TypeScript can help organize these decisions, but it cannot enforce trust in data that arrives at runtime.

Guided lab

Write an async adapter that accepts AbortSignal, maps a provider error into a typed domain error, and returns a discriminated result for an expected “not found” case. Test timeout and cancellation behavior as well as malformed thrown values. The adapter should make the boundary decisions visible: what is validated, what is mapped, which outcome is expected, and which failures remain exceptional.

Complete the lab with this discipline:

  1. Write the requirement and two non-requirements.
  2. List input, output, and error contracts before implementation.
  3. Implement the smallest correct vertical slice.
  4. Add at least one invalid-input test and one edge-case test.
  5. Instrument or inspect the behavior instead of guessing.
  6. Refactor one hidden assumption into an explicit type, constraint, function, or configuration.
  7. Explain one alternative design and why you did not choose it.
  8. Record a short “what would break at 10× scale?” note.

Edge cases and failure modes

  • Promise typing: Test absence, malformed input, duplicates, ordering and concurrency where applicable, and behavior at the smallest and largest credible sizes. Also verify whether a rejected dependency is represented in the contract or merely allowed to escape as a rejection.
  • Caught errors: Test absence, malformed input, duplicates, ordering and concurrency where applicable, and behavior at the smallest and largest credible sizes. Include a thrown string or plain object so the handler proves that it can deal with unknown.
  • Error classes: Test absence, malformed input, duplicates, ordering and concurrency where applicable, and behavior at the smallest and largest credible sizes. Check that stable codes survive mapping while internal stacks and secrets stay out of public responses.
  • Result types: Test absence, malformed input, duplicates, ordering and concurrency where applicable, and behavior at the smallest and largest credible sizes. Exercise both discriminated branches and distinguish expected failures from unexpected exceptions.
  • AbortSignal: Test absence, malformed input, duplicates, ordering and concurrency where applicable, and behavior at the smallest and largest credible sizes. Check an already-aborted signal, cancellation during work, timeout behavior, and what the caller observes after cancellation.

Common mistakes and debugging

  • Solving the example instead of the requirement: a copied pattern can be syntactically correct but architecturally wrong.
  • Hiding uncertainty with assertions, broad exception handlers, permissive schemas, or “temporary” any values.
  • Testing only the happy path and therefore discovering contracts only after integration.
  • Optimizing before measuring, or selecting a scalable mechanism without a scale requirement.
  • Letting client-side behavior stand in for server-side authorization, validation, or persistence guarantees.

For debugging, reproduce the smallest failing case and inspect the actual value or execution plan. Trace the boundary where the invariant first becomes false, then fix the layer that owns that invariant instead of adding a downstream patch. For asynchronous issues, inspect whether the promise fulfilled or rejected, whether the thrown value is actually an Error, whether the signal was passed to the provider, and whether concurrent results were combined with the intended combinator.

Interview questions

  1. What problem does Promise typing solve, and what trade-off or failure mode would make you choose a different approach?
  2. What problem do caught errors solve, and what trade-off or failure mode would make you choose a different approach?
  3. What problem do error classes solve, and what trade-off or failure mode would make you choose a different approach?
  4. What problem do result types solve, and what trade-off or failure mode would make you choose a different approach?
  5. What problem does AbortSignal solve, and what trade-off or failure mode would make you choose a different approach?

Checkpoint

Without notes, explain Async TypeScript, Promises, Errors, Result Types, and Cancellation to another developer in five minutes. Your explanation must include one invariant, one edge case, one production failure mode, and one alternative design. Then implement a small example without copying the lesson code. Be prepared to explain where compile-time guarantees stop and runtime validation or provider behavior begins.

Mastery checklist

  • I can define the core terms precisely.
  • I can choose a design from requirements instead of from habit.
  • I can implement and test the normal path and edge cases.
  • I can explain the runtime, storage, or complexity cost.
  • I can identify which layer owns validation, errors, and recovery.
  • I can compare at least two reasonable alternatives.
  • I can explain how the design changes at larger scale or stricter reliability.

References

Reader page: /typescript/lesson/173/async-typescript-promises-errors-result-types-and-cancellation