Patterns for Agentic Programming1-12026.10.20

Bundle the data each state needs into its own type

When a system has several states, putting the data for all of them into one structure causes trouble. Each state needs different fields, yet a single object carries every field as optional, so invalid combinations of data can be created at will.

Take a music player. It has an empty state with no song selected (Empty), a state where a song is playing (Playing), and a paused state where playback has stopped (Paused). Written as a single class, it usually looks like this.

from dataclasses import dataclass


@dataclass
class Player:
    status: str  # "EMPTY", "PLAYING", "PAUSED"
    song_name: str | None = None

The code looks simple, but it makes the space of states the system can represent far too wide.

A status flag and optional fields create nonsense combinations

In the Player model above, status can take 3 values, and song_name can take 2 kinds of value: a string or None. So the system can represent 3×2=63 \times 2 = 6 states in total.

But if you look at the actual rules of a music player, only 3 states are valid:

  1. status == "EMPTY" and song_name is None (valid: no song)
  2. status == "PLAYING" and song_name is a valid string (valid: a song is playing)
  3. status == "PAUSED" and song_name is a valid string (valid: a song is paused)

The other 3 are illegal states that must never exist in the system:

  • status == "EMPTY" but song_name holds a song name
  • status == "PLAYING" but song_name is None (playing, yet no song)
  • status == "PAUSED" but song_name is None (paused, yet no song)
statussong_name is Nonesong_name == "a title"
"EMPTY"validillegal
"PLAYING"illegalvalid
"PAUSED"illegalvalid

Defensive code multiplies at every consumer

Once the model allows illegal states, every function that uses this data pays for it.

Say you write a function that shows the name of the song now playing. To a static type checker, song_name is str | None, so it cannot rule out None.

def playback_title(player: Player) -> str:
    # Even when status is "PLAYING", song_name may be None, so we need a guard
    if player.status == "PLAYING":
        if player.song_name is not None:
            return player.song_name
        raise ValueError("Playing, but there is no song name")
    return "Not playing"

The same goes for the function that builds the notification text.

def notification_text(player: Player) -> str:
    if player.status in ("PLAYING", "PAUSED"):
        if player.song_name is not None:
            return f"Selected song: {player.song_name}"
        raise ValueError("There is no song name")
    return "No song selected"

Every place that reads song_name has to repeat the if player.song_name is not None check. Miss it in a single place and a NoneType error appears at runtime. An incomplete model makes defensive conditionals spread through all of the downstream code.

Bundle only the data each state needs into a type

The fix is to give each state an independent type that holds only the data that state needs, and to combine them into a union type.

from dataclasses import dataclass


@dataclass(frozen=True)
class Empty:
    pass


@dataclass(frozen=True)
class Playing:
    song_name: str


@dataclass(frozen=True)
class Paused:
    song_name: str


# The union of every state a music player can be in
Player = Empty | Playing | Paused

This design guarantees three things:

  1. The Empty type has no song_name field at all. Putting a song name into a state with no song is impossible.
  2. In Playing and Paused, song_name is a plain str, not str | None. You cannot create the object without a valid song name.
  3. The @dataclass(frozen=True) option makes the objects immutable. Once an instance is created its fields cannot be changed (a FrozenInstanceError is raised), so a state object cannot become inconsistent along the way.

Now the model can represent exactly the 3 states we defined. The 3 illegal states have been removed at the level of the type definitions.

The consumers get simpler

With the states separated, the signatures and bodies of the consuming functions become dramatically simpler.

def playback_title(state: Playing) -> str:
    return state.song_name


def notification_text(state: Playing | Paused) -> str:
    return f"Selected song: {state.song_name}"

The conditionals that checked for None are gone from both functions. The argument type is already restricted to Playing | Paused, so inside the function the song name can be used directly, with no doubt that it exists.

This design makes a decisive difference when coding with agents:

  • Fully local reasoning: an agent can tell what data it must prepare just from the signature of playback_title, without searching the project's comments or earlier execution paths.
  • Mechanical self-correction feedback: if an agent passes an Empty object with no song selected, as in playback_title(Empty()), the static type checker flags the error right away at type-checking time. Without relying on prompt instructions, a closed loop forms in which the agent fills in the right branch on its own from the tool's static error message.

A data rule that must hold at all times while the system runs, such as "a playing or paused state always has a string song name", is called an invariant. Instead of patching that invariant with conditionals everywhere it is used, you build it into the model so that invalid states cannot be created at all. That is the principle of making illegal states unrepresentable.

Where the principle comes from

Yaron Minsky popularized the slogan in his 2010 Harvard lecture Effective ML and his 2011 Jane Street post Effective ML Revisited. He stressed static modeling that uses union types to represent exactly the valid combinations of data. The idea of invariants also connects to C. A. R. Hoare's axiomatic approach and to Bertrand Meyer's Design by Contract. Minsky's publications are listed in the references.

Rules to put in the harness

  • Design principle (separate states, limit fields):
    • Do not put a state flag and the optional (nullable) fields of every state flat in a single structure.
    • Define an immutable structure per state holding only the data that state needs, and combine them into a union or algebraic data type (ADT) so the representable state space is limited to valid combinations.
  • Mechanical gate (enforce static type checking):
    • Verify with a static type checker or linter that functions handling the state union cannot access a specific state's attributes without explicit type narrowing or a conditional branch.
  • Rejection criteria (code review checklist):
    • Reject code that skips a null check or a state check by relying on the implicit assumption that "this field will always exist in this state".
Dartwork

AI Software Development, Numerical Analysis, Engineering R&D

© 2026 Dartwork Co., Ltd. All rights reserved. Seoul