State Unions

The Course
•
5 min read

A record with loading:boolean, optional data and optional error permits combinations the UI may not know how to handle: loading with old error, no data despite success, or both data and error. A discriminated state union makes the intended alternatives explicit.

Our catalogue uses idle, loading, ready and error. Loading carries a request identity; ready carries books and the identity that produced them; error carries a readable message. Each variant exposes only the fields that make sense there.

The compiler can enforce construction and safe access. It cannot decide which async response is newest or whether a request id was reused. Those are runtime transition rules. A reducer is a function that receives the current state and an action (a record describing what happened), then returns the next state without mutating the original. Separate the state shape from that transition logic. The next two lessons build small reducers.

A state union is a model, not a UI framework. This console course formats labels; a webpage could render the same state later. Keep the type small enough that every branch has an obvious meaning.

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 State =
  | { tag: "idle" }
  | {
      tag: "loading";

      request: number;
    }
  | {
      tag: "ready";

      count: number;
    };

function label(state: State): string {
  if (state.tag === "idle") {
    return "Start";
  }

  if (state.tag === "loading") {
    return `Loading ${state.request}`;
  }

  return `${state.count} books`;
}

console.log(label({ tag: "idle" }));
console.log(label({ tag: "ready", count: 0 }));

Enable JavaScript to edit and type-check this example.

Check your prediction
Start
0 books

Read the compiler error

The tag selects a branch whose data is required.

Find and fix the type errorTypeScript
type State =
  | { tag: "idle" }
  | {
      tag: "ready";

      title: string;
    };

const state: State = { tag: "ready" };

console.log(state);

Enable JavaScript to edit and type-check this example.

Check your diagnosis

Supply title for ready. Do not make the field optional merely to permit an invalid ready state.

Make invalid combinations hard to construct

A state union encodes which fields belong together. Ready requires data; error requires a message; idle needs neither. A single record of optional data and error fields permits combinations that every renderer must reinterpret.

Types establish valid shapes, while transition code establishes time and ownership. The compiler cannot choose whether old data remains visible during refresh, and it cannot know whether a numeric request id was reused. Keep those policies explicit in the state machine.

Make invalid combinations hard to construct: traceTypeScript
type State =
  | { tag: "idle" }
  | {
      tag: "ready";

      items: readonly string[];
    }
  | {
      tag: "error";

      message: string;
    };

function label(state: State): string {
  if (state.tag === "idle") {
    return "Start";
  }

  if (state.tag === "error") {
    return state.message;
  }

  return state.items.length === 0 ? "No matches" : state.items.join(",");
}

console.log(label({ tag: "idle" }));
console.log(label({ tag: "ready", items: [] }));

Enable JavaScript to edit and type-check this example.

Check your prediction
Start
No matches

The empty ready state represents a completed response, not an untouched application. Both can have zero records but need different messages.

Make invalid combinations hard to construct: contrastTypeScript
type State =
  | {
      tag: "loading";

      request: number;
    }
  | {
      tag: "ready";

      title: string;
    };

const before: State = { tag: "loading", request: 2 };

function finish(title: string): State {
  return { tag: "ready", title };
}

const after = finish("Types");

console.log(before.tag, after.tag);

Enable JavaScript to edit and type-check this example.

Check your prediction
loading ready

A new state object represents the transition. This toy does not check request identity; that responsibility is added by the reducer checkpoint rather than magically provided by the union.

Try the rule on new inputs

Construct one value for each state and list its required fields. Then add a refreshing state and decide whether it intentionally carries old data. Test the corresponding transition separately from construction errors.

Your turn

Exercise

State Unions: practise

Define State with idle, ready carrying title:string, and error carrying message:string. Write a label function and print No selection, Types and Try again for the three variants.

Your workspaceTypeScript
// Do not model this using optional title and message on one undifferentiated record.

Enable JavaScript to edit and type-check this example.

Hint 1: the idea

Each branch should have exactly the data required for its state.

Hint 2: a first step

Use tag as the shared literal field and branch before reading a variant field.

Show one solution
One possible solutionTypeScript
type State =
  | { tag: "idle" }
  | {
      tag: "ready";

      title: string;
    }
  | {
      tag: "error";

      message: string;
    };

function label(state: State): string {
  if (state.tag === "idle") {
    return "No selection";
  }

  if (state.tag === "ready") {
    return state.title;
  }

  return state.message;
}

console.log(label({ tag: "idle" }));
console.log(label({ tag: "ready", title: "Types" }));
console.log(label({ tag: "error", message: "Try again" }));

Enable JavaScript to edit and type-check this example.

No selection
Types
Try again

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

Try constructing ready without title and error without message. Then propose a refreshing state and decide whether it deliberately carries previously loaded data. The compiler will check the shape you choose; it cannot choose the user experience for you.

Was this article helpful?

Your response is saved on this device.