←All posts

Practical Algebraic Data Types — Modeling States and Errors, and Trade-offs with Inheritance

Replace confusing flags and nullable fields with explicit states so the compiler catches contradictory combinations and missing cases. Runtime checks remain for external input, authorization, and concurrency, and interfaces often suit interchangeable implementations better than unions. Includes four interactive experiments.

TypeScriptType SystemSoftware DesignInteractive

An order is marked paid but has no payment ID. A loading screen shows both a result and an error. A new state is added, but one report still uses the old cases. Careful conditional logic alone does not reliably prevent these defects. The representation itself may make contradictory combinations easy to construct.

Algebraic data types (ADTs) let you represent the states a value can take and the fields each state requires. The compiler can then reject combinations the requirements never use, and you can have it report branches missed when a state is added. ADT here means algebraic data type, not abstract data type. The examples model request states and an order's payment and refund.

The examples are checked with TypeScript 5.9.3 using strict: true (including strictNullChecks), target: "ES2022", and lib: ["ES2022", "DOM"].

You need no mathematical background, only familiarity with objects and if statements. T and E in the code are type parameters; callers supply concrete types for them.

What does this help with?

Recurring problemWhat changes with an ADTWhat still needs separate attention
Complicated flag combinationsEnumerate meaningful statesCheck that those states match the requirements
Different fields are required in different statesPut required fields in their state's typeValidate amounts and the existence of IDs
Missing branches after adding a stateAdd exhaustiveness checksTest the calculations and behavior inside each branch
Expected failures get buried in exception handlingDistinguish success and failure in return valuesLogging, recovery, and retry decisions
Uncertainty about inheritance versus switchIdentify whether cases or operations are growingExtension boundaries, public APIs, and team ownership

Some if statements will remain. Errors the compiler can detect without execution go to the type checker, and runtime checks focus on facts only execution can establish.

A reading path

  1. Start using it: Represent and update states (sections 1–7). Experiments 1–3 take you to the first milestone: modeling states and updating them according to validation results.
  2. Choose a design: Compare with inheritance (section 8 through chargeNotice in section 9). Experiment 4 and the payment adapter example explain why shared contracts and ADTs work together.
  3. Integrate it: From payment attempts to adoption (the rest of section 9 through section 12). Covers persistence, retries, and steps for adopting this in existing code.

You do not need to memorize every design consideration on the first reading. Start by reorganizing one group of flags and required fields from your own code into meaningful states. The code blocks build on types and functions introduced earlier.

1. Flags and null do not express relationships between fields

Consider a screen that loads a user. During a refetch, this particular screen hides the previous result. It displays exactly one of idle, loading, success, or failure.

type User = { readonly name: string };
 
type LegacyUserRequest = {
  isLoading: boolean;
  data: User | null;
  error: string | null;
};
 
const contradictory: LegacyUserRequest = {
  isLoading: true,
  data: { name: "Aki" },
  error: "NETWORK_ERROR",
};

This passes type checking because each field matches its individual type. But the screen does not use a state that is loading while also holding success data and a failure reason.

Counting the two values of isLoading and whether data and error are present, ignoring payload contents, gives 2 × 2 × 2 = 8 combinations. The specification uses only these four:

StateisLoadingdataerror
Idlefalsenullnull
Loadingtruenullnull
SuccessfalsePresentnull
FailurefalsenullPresent

Experiment 1: Explore the states allowed by flags

Toggle the fields and find combinations that satisfy the type but violate this screen's requirements.

This screen hides previous results while refetching. It uses only four states: idle, loading, success, and failure.

Change the three fields independently

Flags and nullable fields

{
  "isLoading": false,
  "data": null,
  "error": null
}

Discriminated union representation

This combination satisfies the requirements.

{
  "kind": "idle"
}
All combinations / Valid combinations (8 / 4)

Digits mean loading / data present / error present. 1 means present or true; 0 means absent or false. Payload contents are fixed: only presence combinations are counted.

Select both “Data present” and “Error present.” The representation satisfies the flag-based type, but not this screen's requirements.

Retaining data is correct for a screen that shows cached results during a refetch. Requirements determine which states are invalid.

2. Combine “all of these” with “one of these”

ADTs are built by combining two kinds of types:

  • Product types: hold both A and B. Objects and tuples are familiar examples.
  • Sum types: select A or B. Each alternative can carry different information.

For finite choices, a setting with “notifications on/off” and “Japanese/English” has 2 × 2 = 4 combinations. In contrast, “not started” or “succeeded with a result” adds one not-started possibility to the possible success results. This addition and multiplication is where the name “algebraic” comes from. To get started in practice, recognizing “all required” versus “one alternative” is enough.

We can rewrite the request states as a TypeScript discriminated union:

type RemoteData<T, E> =
  | { readonly kind: "idle" }
  | { readonly kind: "loading" }
  | { readonly kind: "success"; readonly data: T }
  | { readonly kind: "failure"; readonly error: E };
 
const loaded: RemoteData<User, string> = {
  kind: "success",
  data: { name: "Aki" },
};
 
// @ts-expect-error: success requires data
const missingData: RemoteData<User, string> = { kind: "success" };

| selects an alternative. Each object is a product grouping the fields that belong together. A field such as kind that distinguishes the alternatives is the discriminant. It could also be named status or type.

kind: "success" permits the specific string "success", not any string. Consequently, the compiler can check that a success has data and a failure has error. Making data? an optional field shared by every state lets a success without data pass type checking.

@ts-expect-error checks that the following line produces a type error. It is here to test an intentionally incorrect example.

Strictly speaking, a TypeScript A | B is not in general a disjoint sum. Here, distinct discriminant values separate the alternatives. TypeScript object types also do not guarantee that extra properties are absent. Section 6 covers this together with validating external input.

3. Make the success case visible to the compiler

Reading data without first checking for success is an error:

function unsafeName(state: RemoteData<User, string>): string {
  // @ts-expect-error: data does not exist in every variant
  return state.data.name;
}

Check the discriminant, and the fields for that alternative become available:

function assertNever(value: never): never {
  throw new Error(`Unexpected variant: ${String(value)}`);
}
 
function userMessage(state: RemoteData<User, string>): string {
  switch (state.kind) {
    case "idle":
      return "Ready";
    case "loading":
      return "Loading";
    case "success":
      return state.data.name;
    case "failure":
      return state.error;
  }
  return assertNever(state);
}

Inside case "success", the compiler narrows state to the success alternative. There is no need to repeatedly test data !== null or use data! to claim that it must exist.

never is a type with no values. When every alternative returns, none remains at the end. If we add a state but forget its branch, that remaining state cannot be passed to a function requiring never. This is an exhaustiveness check.

Experiment 2: Where does adding a state cause a type error?

Select the four examples in order and compare how State and the label function change.

Stage of the change

Type check passes: after handling all four states, state has type never.

type State =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "success"; data: string }
  | { kind: "failure"; error: string };

function assertNever(value: never): never {
  throw new Error(`Unexpected variant: ${String(value)}`);
}

function label(state: State): string {
  switch (state.kind) {
    case "idle": return "Ready";
    case "loading": return "Loading";
    case "success": return state.data;
    case "failure": return state.error;
  }
  return assertNever(state);
}

Repository tests check these displayed examples with the actual TypeScript compiler. This is not an editor that compiles code inside your browser.

Add cancelled, then add its branch. Finally, select the default-based version: the type check passes, but cancelled requests now display the same message as idle requests.

Even with default, writing default: return assertNever(state) keeps the exhaustiveness check. The problem is returning a normal-looking default for a case nobody considered. If “all other cases” is intentional, document that policy in the model or requirements.

Missing cases are caught in functions that have exhaustiveness checks, return-type checks, or similar checks. Run a type checker such as tsc --noEmit in CI rather than relying on transpilation alone.

4. Defensive programming: separate checks rather than remove them

A flag-based design often repeats “success but no data” defenses in rendering, reports, and notifications. When a success type requires its payload, internal code working with validated values needs fewer repeated checks for that same structural contradiction.

Fact to establishMain responsibility
A success has its required payloadInternal type checking
Every alternative has a branchExhaustiveness checking with never
JSON has the required fieldsRuntime validation at an input boundary
An amount is within the valid rangeConstructors, validation, and database constraints
The caller may issue a refundAuthorization
Another process has not updated the same orderTransactions and conditional updates
The success branch calculated the correct amountTests and business-rule review

TypeScript annotations do not remain at runtime. as Order does not validate JSON, and readonly does not freeze an object. TypeScript is not designed as a fully sound type system either. any, assertions, incomplete type guards, and mutation through other references can undermine type assumptions.

So validate values received from outside first, and preserve the validated representation in the code that follows.

5. Specify the fields each order state requires

We will use only three states: unpaid draft, paid, and fully refunded. Partial refunds, in-flight payments, cancellation, and retries are initially excluded.

type OrderFields = {
  readonly orderId: string;
  readonly amountYen: number;
};
 
type DraftOrder = OrderFields & {
  readonly kind: "draft";
};
 
type PaidOrder = OrderFields & {
  readonly kind: "paid";
  readonly paymentId: string;
};
 
type RefundedOrder = OrderFields & {
  readonly kind: "refunded";
  readonly paymentId: string;
  readonly refundId: string;
};
 
type Order = DraftOrder | PaidOrder | RefundedOrder;

amountYen is the order total, not the amount remaining to be refunded. A full refund does not change this total in our model. If a remaining balance is needed, derive it separately from the original payment amount and refund records.

& combines common fields with the state-specific fields. Here the combined fields have distinct names, but in general a TypeScript intersection A & B is not the same as a mathematical Cartesian product. If properties with the same name have contradictory types, the result can be a type with no values.

With optional paymentId?: string and refundId?: string, consumers need repeated presence checks. In the new representation, a paid order has paymentId, and a refunded order has both IDs.

However, a number can be negative or fractional, and a string can hold a payment ID that does not exist. Check a field's presence and its business validity separately.

Restrict an operation's input to the state that supports it

function recordPayment(
  order: DraftOrder,
  paymentId: string,
): PaidOrder {
  return {
    kind: "paid",
    orderId: order.orderId,
    amountYen: order.amountYen,
    paymentId,
  };
}
 
function recordRefund(
  order: PaidOrder,
  refundId: string,
): RefundedOrder {
  return {
    kind: "refunded",
    orderId: order.orderId,
    amountYen: order.amountYen,
    paymentId: order.paymentId,
    refundId,
  };
}
 
function invalidRefundExample(draft: DraftOrder): void {
  // @ts-expect-error: a draft is not a paid order
  recordRefund(draft, "refund-demo");
}

Recording a refund requires a PaidOrder. Passing an unpaid DraftOrder is caught at the call site. Using types to distinguish which operations a state supports is known as typestate.

These functions only record completed payments and refunds as new order values. They do not contact a payment service. Callers must establish these preconditions:

  • Before recording payment, a payment for this order, total amount, and currency has completed.
  • Before recording a full refund, a refund of the same amount and currency against this paymentId has completed.

A nonblank ID or an API acknowledging a request is not sufficient. If acceptance and completion are separate, verify a completion notification or query result.

The old value is not consumed, so the variable draft still exists after recordPayment(draft, id). This form of typestate in TypeScript does not guarantee one-time use. Returning a new value does not prevent two updates to the same database row; that needs a separate mechanism (section 10).

6. Accept unknown input and return a typed validation result

Values from HTTP, forms, databases, or message queues arrive from outside the type system. Our next function is an entry point for loading a new unpaid order, not a general decoder for every persisted order state.

First, represent validation success and failure:

type Result<T, E> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly error: E };
 
type DecodeError =
  | "not-object"
  | "not-draft"
  | "invalid-order-id"
  | "invalid-amount";

unknown requires checking a value before using it. This is different from any, which bypasses relevant type checks.

function decodeDraft(value: unknown): Result<DraftOrder, DecodeError> {
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
    return { ok: false, error: "not-object" };
  }
  if (!("kind" in value) || value.kind !== "draft") {
    return { ok: false, error: "not-draft" };
  }
  if (
    !("orderId" in value) ||
    typeof value.orderId !== "string" ||
    value.orderId.trim().length === 0
  ) {
    return { ok: false, error: "invalid-order-id" };
  }
  if (
    !("amountYen" in value) ||
    typeof value.amountYen !== "number" ||
    !Number.isSafeInteger(value.amountYen) ||
    value.amountYen <= 0
  ) {
    return { ok: false, error: "invalid-amount" };
  }
  return {
    ok: true,
    value: {
      kind: "draft",
      orderId: value.orderId,
      amountYen: value.amountYen,
    },
  };
}

This example accepts only positive safe integers in Japanese yen. It rejects 0, fractions, NaN, infinity, unsafe integers, and the string "1200". A production system also needs rules for currency, minor units, upper bounds, tax, and rounding, and may need integer or decimal libraries.

After checking the input, we build a fresh value with only the required fields rather than returning the input object. This entry point ignores unrecognized properties without copying them and rejects unknown kind values. If an API must reject extra properties too, add an allowed-key check.

Because TypeScript is structural, assignment can accept extra properties when an object comes through a variable. Excess-property checking applies only when a fresh object literal is passed directly, so it cannot validate external input.

A JSON syntax error is not a success

function parseDraftJson(
  source: string,
): Result<DraftOrder, DecodeError | "invalid-json"> {
  let value: unknown;
  try {
    value = JSON.parse(source);
  } catch (error) {
    if (error instanceof SyntaxError) {
      return { ok: false, error: "invalid-json" };
    }
    throw error;
  }
  return decodeDraft(value);
}

Parsing JSON syntax and checking its contents against business input requirements are separate steps. Store JSON.parse's result in unknown, then validate its contents with decodeDraft. Only syntax errors are converted here; other exceptions are rethrown.

For larger inputs, schema libraries can reduce duplication between type declarations and runtime validation. Authorization and the existence of referenced records are checked separately from the schema. For a hand-written value is Order predicate, use tests and review to confirm that its implementation actually checks its declared condition.

7. Dynamic operations also need runtime transition checks

When a caller already knows it has a PaidOrder, it can call the refund-recording function directly. But an operation arriving from a screen or queue and a current state loaded from a database are not paired until runtime.

In that situation, check each combination of Order × OrderEvent:

Current stateRecord paymentRecord refund
draftMove to paidReject
paidRejectMove to refunded
refundedRejectReject
type OrderEvent =
  | { readonly type: "payment-recorded"; readonly paymentId: string }
  | { readonly type: "refund-recorded"; readonly refundId: string };
 
type TransitionError =
  | {
      readonly kind: "invalid-transition";
      readonly state: Order["kind"];
      readonly event: OrderEvent["type"];
    }
  | {
      readonly kind: "invalid-reference";
      readonly field: "paymentId" | "refundId";
    };
 
function applyOrderEvent(
  order: Order,
  event: OrderEvent,
): Result<Order, TransitionError> {
  switch (event.type) {
    case "payment-recorded":
      if (event.paymentId.trim().length === 0) {
        return {
          ok: false,
          error: { kind: "invalid-reference", field: "paymentId" },
        };
      }
      if (order.kind !== "draft") {
        return {
          ok: false,
          error: {
            kind: "invalid-transition",
            state: order.kind,
            event: event.type,
          },
        };
      }
      return { ok: true, value: recordPayment(order, event.paymentId) };
    case "refund-recorded":
      if (event.refundId.trim().length === 0) {
        return {
          ok: false,
          error: { kind: "invalid-reference", field: "refundId" },
        };
      }
      if (order.kind !== "paid") {
        return {
          ok: false,
          error: {
            kind: "invalid-transition",
            state: order.kind,
            event: event.type,
          },
        };
      }
      return { ok: true, value: recordRefund(order, event.refundId) };
  }
  return assertNever(event);
}

Order["kind"] extracts the allowed discriminant strings from Order, avoiding a separately maintained list of state names in the error type.

The assertNever(event) in this function checks event alternatives. Adding an order state does not cause a type error here: the existing if conditions reject operations from that new state. This is an explicit allowlist policy. Rejection alone, however, does not mean anyone has reviewed how the new state should behave. If adding a state must force reconsideration of its transitions, use an exhaustive switch over states too, explicitly representing rejections.

This function accepts events whose structure has already been validated. It checks that the record ID is not blank and that the operation is legal in the current state. Validate external JSON as in section 6 before passing it in.

Experiment 3: Separate input validation from state transitions

Validate JSON to load an order, then try recording a refund before payment or recording payment twice.

Choose an input (selection alone does not change the order)

Successful validation replaces the current order with a new draft. This restarts the experiment; it is not a business state transition.

Current order

  1. draft
  2. paid
  3. refunded
{
  "kind": "draft",
  "orderId": "order-42",
  "amountYen": 1200
}

amountYen is the order total. Recording a full refund does not change this value.

The input has not been loaded yet. You can operate on the current order.

This experiment runs entirely in your browser. It makes no network calls or payments. Record IDs are fixed samples; verifying real payments and controlling concurrent updates are out of scope.

Try recording a refund on the initial draft. Then record payment followed by refund. Next, load a string-valued amount or an unknown kind. When input is rejected, the current order is not reset to a draft.

This small model only rejects repeated payment records. A production event consumer also needs an idempotency policy, such as returning the same result for redelivery of the same event.

Does Result guarantee that errors are handled?

A useful property is that value is unavailable until the ok branch is established. However, TypeScript permits calling a function that returns Result and discarding the entire result. Returning Result therefore does not guarantee that every caller executes failure handling.

Consumers should show correctable input issues or explain invalid operation order and conflicts. Return the facts needed for recovery, and assign responsibility for logging and notification. Do not turn every failure into false or return an empty order just to keep running.

Using Result for expected business failures and exceptions for bugs or unexpected failures is a useful design convention. TypeScript does not enforce thrown exception types in function signatures, and Promise<Result<T, E>> can still reject. The top-level asynchronous boundary must separately record and report unexpected failures.

Consumers replace the order only after success

We can express the demo's control flow with a small screen model independent of React or the DOM. It holds the current order and a message to display:

type OrderScreen = {
  readonly order: Order;
  readonly notice: string;
};
 
function loadDraftIntoScreen(
  screen: OrderScreen,
  source: string,
): OrderScreen {
  const decoded = parseDraftJson(source);
  if (!decoded.ok) {
    return { ...screen, notice: `Input rejected: ${decoded.error}` };
  }
  return { order: decoded.value, notice: "Draft loaded" };
}
 
function dispatchToScreen(
  screen: OrderScreen,
  event: OrderEvent,
): OrderScreen {
  const result = applyOrderEvent(screen.order, event);
  if (!result.ok) {
    const reason = result.error.kind === "invalid-transition"
      ? `${result.error.state} + ${result.error.event}`
      : `${result.error.field} is empty`;
    return { ...screen, notice: `Operation rejected: ${reason}` };
  }
  return { order: result.value, notice: "Order updated" };
}

The !decoded.ok branch reads error; only the success branch adopts value. On failure, { ...screen, notice: ... } keeps the order and updates only the message. Subsequent operations apply to the current screen.order.

let orderScreen: OrderScreen = {
  order: { kind: "draft", orderId: "initial", amountYen: 1 },
  notice: "Ready",
};
 
orderScreen = loadDraftIntoScreen(
  orderScreen,
  '{"kind":"draft","orderId":"order-42","amountYen":1200}',
);
orderScreen = dispatchToScreen(
  orderScreen,
  { type: "payment-recorded", paymentId: "pay-demo" },
);
orderScreen = dispatchToScreen(
  orderScreen,
  { type: "refund-recorded", refundId: "refund-demo" },
);

The final order is refunded. Loading input and operating on the loaded order are separate actions. Recreating the order from the original JSON on every operation would reset it to draft each time. In a real UI, store the returned screen model and display notice. Persistence, authorization, and concurrency control still need the separate mechanisms listed in the section 4 table.

8. How does a sum type differ from inheritance?

Object-oriented inheritance can also use a class for each state. For example, presentation can be delegated to objects:

abstract class OrderView {
  abstract label(): string;
}
 
class DraftView extends OrderView {
  override label(): string {
    return "Awaiting payment";
  }
}
 
class PaidView extends OrderView {
  constructor(private readonly paymentId: string) {
    super();
  }
 
  override label(): string {
    return `Paid: ${this.paymentId}`;
  }
}
 
function viewLabel(view: OrderView): string {
  return view.label();
}

viewLabel calls label() without knowing concrete class names. The object supplied determines the implementation invoked: dynamic dispatch.

A union-consuming function instead inspects a discriminant and selects the operation:

PerspectiveShared base type or interfaceDiscriminated union
What consumers rely onA contract of shared operationsEnumerated cases and their data
Typical operation placementMethods in each classFunctions outside the type
Adding a new kindAdd a type implementing the existing contractExtend the union and inspect consumers
Can every kind be enumerated?Not generally for an open hierarchyDeclared alternatives can be checked
Useful examplesStorage, payment adapters, pluginsBusiness states, results, commands, syntax trees

TypeScript's abstract class does not close the set of subclasses. TypeScript also checks interfaces structurally, so sharing a contract does not always require extends.

Inheritance and ADTs are not mutually exclusive

Some languages can restrict the permitted subtypes, as Java does with sealed classes and interfaces. Closing branches with final or further sealed declarations allows alternatives to be managed through a class hierarchy. A non-sealed branch reopens extension, so the set of all concrete types need not be finite.

In other words, objects with behavior and a restricted set of alternatives can coexist. “Inheritance for OOP, ADTs for functional programming” is not enough to choose a design.

Inheritance does not describe the chronological order of business states

Be careful with the idea that PaidOrder extends DraftOrder expresses “payment has now happened.” Inheritance primarily creates a relationship in which the subtype can be used as the base type. But a paid order should not necessarily be usable as an unpaid order.

Similarly, if a base contract promises that every order can be refunded, an unpaid subtype that refuses refunds breaks the caller's expectation. A contract that explicitly allows failure is a different situation. Even then, a function accepting only PaidOrder can express its precondition more clearly.

Advancing a state can create a new value or object. It does not require changing the runtime class of an existing object.

9. Adding cases and adding operations favor different organizations

Placing order states in rows and operations such as label and toDto in columns makes it easier to compare how code is organized:

Experiment 4: Adding a state versus adding an operation

The same requirement affects different locations depending on whether code is organized by state or by operation.

New requirement
Each cell represents a design decision: how this operation behaves for this state.
State / Operationlabel()toDto()
draftExistingExisting
paidExistingExisting
refundedExistingExisting
cancelledNewNew

Union + external functions

Extend the union and update label and toDto. Exhaustiveness checks using never identify functions that need updating.

Per-state classes + methods

Implement label and toDto in a Cancelled class. Callers using only the shared contract can stay unchanged, though construction and registration may still need updates.

This compares representative code organizations, not measured editing cost or performance. Shared implementations, Visitors, and sealed hierarchies provide additional design choices.

With a union and external functions, adding an operation means writing one new function over the existing cases. Adding a state means updating branches in several functions, where exhaustiveness checks help identify the required work.

With operations grouped into per-state classes, adding an operation to the common contract is more likely to require changes across the classes. Adding a state means implementing the existing operations in one new class.

This leads to what is known as the Expression Problem: extending both cases and operations without changing or recompiling existing code, while preserving static type safety.

The experiment compares representative code organizations. Visitors, inherited implementations, and extension functions can change the distribution of work. With any of them, someone still has to decide what rendering, persistence, and auditing mean for a new state.

A practical combination: an interface for adapters, an ADT for results

An open contract works well for interchangeable payment adapters. For their consumers, enumerating outcomes lets the type checker catch unhandled ones:

type PaymentAttemptIdentity = {
  readonly attemptId: string;
  readonly idempotencyKey: string;
};
 
type ChargeFailure =
  | { readonly kind: "declined"; readonly reason: string }
  | { readonly kind: "outcome-unknown"; readonly attemptId: string };
 
type ChargeResult = Result<
  { readonly paymentId: string },
  ChargeFailure
>;
 
interface PaymentGateway {
  readonly charge: (
    order: DraftOrder,
    attempt: PaymentAttemptIdentity,
  ) => Promise<ChargeResult>;
}

Adapter-specific authentication and transport live in the implementation. Consumers distinguish success, definite decline, and an outcome that cannot yet be confirmed. attemptId is a caller-issued ID persisted before the network call, distinct from the provider's payment ID. The contract returns the same input attemptId for an unknown outcome, so the caller can identify its attempt even without receiving a provider response.

A timeout, for example, is not evidence that a charge did not occur. An unknown outcome needs a provider-supported response, such as querying by a supported transaction ID or idempotency key, or resending with that same key. A caller-generated attemptId is not necessarily a valid lookup key for the provider's API.

The interface does not implement provider-side idempotency. Persisting and reusing keys, response semantics for repeated keys, and reconciliation after persistence failures all need explicit design.

Delegate adapter differences, but interpret outcomes explicitly

Here are approving and declining test implementations with no network calls. PaymentGateway["charge"] reuses the contract's function type. These classes do not need shared implementation code, so they implement the same interface without inheriting from a common class.

class ApprovingTestGateway implements PaymentGateway {
  readonly charge: PaymentGateway["charge"] = async () => ({
    ok: true,
    value: { paymentId: "test-payment" },
  });
}
 
class DecliningTestGateway implements PaymentGateway {
  readonly charge: PaymentGateway["charge"] = async () => ({
    ok: false,
    error: { kind: "declined", reason: "TEST_DECLINE" },
  });
}
 
async function chargeNotice(
  gateway: PaymentGateway,
  order: DraftOrder,
  attempt: PaymentAttemptIdentity,
): Promise<string> {
  const result = await gateway.charge(order, attempt);
  if (result.ok) {
    return `Paid: ${result.value.paymentId}`;
  }
  switch (result.error.kind) {
    case "declined":
      return `Declined: ${result.error.reason}`;
    case "outcome-unknown":
      return `Check attempt: ${result.error.attemptId}`;
  }
  return assertNever(result.error);
}

The consumer invokes charge without inspecting a concrete class name. The shared contract supports interchangeable implementations; the ADT makes outcome interpretation explicit.

async function gatewayExamples(): Promise<readonly string[]> {
  return [
    await chargeNotice(
      new ApprovingTestGateway(),
      { kind: "draft", orderId: "test-approved", amountYen: 1200 },
      { attemptId: "attempt-approved", idempotencyKey: "key-approved" },
    ),
    await chargeNotice(
      new DecliningTestGateway(),
      { kind: "draft", orderId: "test-declined", amountYen: 1200 },
      { attemptId: "attempt-declined", idempotencyKey: "key-declined" },
    ),
  ];
}

Awaiting gatewayExamples() produces ["Paid: test-payment", "Declined: TEST_DECLINE"]. These are display tests for separate orders; attempt and order persistence are not implemented.

Adding an adapter requires its implementation and composition code that selects and supplies it, not a change to chargeNotice. Adding an outcome to ChargeFailure, in contrast, makes the trailing assertNever fail type checking until consumers handle the new outcome. If charge throws or rejects, this example propagates that failure to its caller rather than misclassifying it as a business decline.

Persist payment attempts separately from order state

An order might remain a draft while the outcome is unknown. That does not make it safe to start another payment. Persist attempts separately to distinguish a never-attempted payment from one whose outcome is unresolved.

type PaymentAttempt = PaymentAttemptIdentity & {
  readonly orderId: string;
  readonly amountYen: number;
  readonly currency: "JPY";
  readonly outcome:
    | { readonly kind: "pending" }
    | { readonly kind: "succeeded"; readonly paymentId: string }
    | { readonly kind: "declined"; readonly reason: string }
    | { readonly kind: "unknown" };
};

Before the call, persist the attempt ID, idempotency key, order, amount, and currency. A resend must use the same attempt and payload. Do not start a separate charge with a new key while the first attempt is pending or unknown. After confirming success, record its paymentId on the order. This is a model extension, not networking added to the three-state demo.

Providers differ in key-retention periods and reconciliation APIs. After a provider deletes its record of a key, resending even the same key may execute a new request. Whether the key is reused or new, do not unconditionally rerun an attempt after its retention window. Reconcile the persisted attempt with external transactions to establish its outcome.

10. Design for what types cannot guarantee

Concurrent updates and duplicate execution

Two processes can read the same draft and each construct a paid value. Types do not prevent this. Persistence might use an update conditional on the order ID and version read earlier, interpreting zero affected rows as a conflict. Transactions and unique constraints on payment IDs can help where appropriate.

An external charge and a local database update generally do not belong to one database transaction. The charge can succeed while saving the order fails. Reconciliation, idempotency, and operational recovery procedures must handle that situation; type checking cannot.

Preserve value constraints inside the program

The decoder validates positive safe integers, but amountYen: number still permits other code to construct a DraftOrder with a negative amount. When more internal construction sites appear, a brand can distinguish validated amounts:

declare const positiveYenBrand: unique symbol;
type PositiveYen = number & { readonly [positiveYenBrand]: true };
 
function positiveYen(
  value: unknown,
): Result<PositiveYen, "invalid-amount"> {
  if (
    typeof value !== "number" ||
    !Number.isSafeInteger(value) ||
    value <= 0
  ) {
    return { ok: false, error: "invalid-amount" };
  }
  return { ok: true, value: value as PositiveYen };
}

The assertion is deliberately confined to the constructor after numeric validation. Keep the brand marker private to its module and expose the constructor as the entry point. Consumers must also change their amountYen field to PositiveYen; only then does passing an unvalidated number become an error.

A brand does not prevent somebody from forcing an assertion, and arithmetic results need revalidation. Adding two PositiveYen values produces a plain number that could exceed the safe range. JSON round-trips do not automatically validate or restore the brand either.

readonly and consistency after changes

readonly restricts assignments through that type; it does not guarantee deep immutability or runtime freezing. If success data is an array, for example, element mutation needs separate consideration. Replacing state with a new value rather than mutating individual fields makes it easier to update the state and its required payload together.

11. When to split a model and when to add a state

Do not flatten independent lifecycles into one huge union

If payment has three states and delivery has four, and they are independent, a product can hold both. The 12 combinations do not each need a named alternative.

If unpaid orders must never ship, however, decide where to enforce that cross-field rule. You might restrict combinations with a higher-level union or validate aggregate operations.

Revisit forbidden states when the requirements change

If the original screen must retain old data during refetching, a refreshing alternative can carry data. If it must keep that data after a failed refetch, a state containing both data and the refresh error is meaningful.

An ADT is not a rule forbidding null or multiple pieces of information. If a combination has a legitimate meaning, represent it explicitly.

Keep wire/storage types distinct from internal models

Old clients may not know new state names. Sharing type definitions does not update already deployed code.

Put a conversion boundary between external data transfer objects, or DTOs, and internal models. Decide whether to reject unknown discriminants, expose them as read-only unsupported cases, or convert based on a version. Do not reinterpret an unknown state as draft or success and then permit operations.

Persisted values also outlive their original schema. Changing a type declaration does not migrate old database rows or queued messages. Plan validation on read and data migration separately.

12. Introduce this incrementally

Starting with one ambiguous state representation, rather than rewriting everything, makes the effect easier to confirm.

  1. List combinations. Identify related flags, nullable fields, and state-specific requirements.
  2. Name meaningful states. Include real failures and in-progress states.
  3. Build an entry-point conversion. Do not assert legacy values into the new type.
  4. Migrate consumers one at a time. Rendering or notifications are often manageable starting points for exhaustiveness.
  5. Try an extension. Add a state and confirm that a missing branch becomes a type error.
  6. Remove redundant internal checks. Do not remove boundary, authorization, or concurrency checks with them.

Divide testing between static and runtime responsibilities

  • Type tests: reject missing success data, refunds on drafts, and unhandled added states.
  • Value tests: cover zero, negative and fractional amounts, safe-integer limits, empty IDs, and unknown discriminants.
  • Transition tests: cover every state/event pair, unchanged state after rejection, and no mutation of the original value.
  • Integration tests: cover concurrency, redelivery, reconciliation after timeouts, and persistence failure.
  • UI tests: show failure reasons and clear stale success feedback after editing input.

A manually maintained array of test states does not detect a missing fixture when the type gains a new alternative. A map keyed by every discriminant can require fixture updates too:

const exampleDraft: DraftOrder = {
  kind: "draft",
  orderId: "order-42",
  amountYen: 1200,
};
 
const orderExamples = {
  draft: exampleDraft,
  paid: recordPayment(exampleDraft, "pay-demo"),
  refunded: recordRefund(
    recordPayment(exampleDraft, "pay-demo"),
    "refund-demo",
  ),
} satisfies { [K in Order["kind"]]: Extract<Order, { kind: K }> };

satisfies checks a value against a type constraint. Extract<Order, { kind: K }> selects only the members of Order whose kind matches K. Here the map requires every state name as a key and a correctly matched variant for each value. An equivalent event map lets tests enumerate all state/event pairs with less risk of missing fixture updates. Expected transition behavior must still be checked against the requirements.

Type checking examines branches even when tests never execute them. Tests and specification reviews still own well-typed calculation errors, external behavior, and incorrect state tables. Combining them makes responsibilities clearer than relying on runtime tests alone to find every structural mismatch.

Check your understanding

A type can require paymentId on paid orders. Can it also establish that the payment service actually knows that ID?

Not with an ordinary string type or discriminated union alone. Validate the provider response and its relationship to stored records. A brand can reduce internal mix-ups, but you still need to establish that the ID belongs to this order rather than another one.

Should interchangeable S3-compatible, local, and test storage implementations always be a union?

If callers should not know each implementation whenever one is added, a shared interface is often more suitable. Its operation results can still use an ADT such as success, not found, or access denied.

If an exhaustive switch passes type checking, are refund tests unnecessary?

No. Exhaustiveness establishes that a branch exists for each case, not that it refunds the correct amount or prevents duplicate execution.

Summary

The first practical step is listing business states and their required information before adding more related flags.

Use products to group required data and sums to distinguish alternatives. This lets the compiler catch missing payloads and forgotten cases. Input validity, authorization, concurrency, and external outcomes still require runtime design.

Replacing inheritance and interfaces is not the goal. Use shared contracts for implementations you swap or extend, and ADTs for states and outcomes consumers must distinguish. That division is a practical starting point for adopting these ideas in an existing codebase.

References and further reading

The external sources below are primary sources for the language rules and design trade-offs. The code in this article was written for this explanation.