Temporal Coupling
When calls must happen in an order the type system does not know about, the ordering lives in someone's head — and heads leave.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
How do I design an interface so that the invalid call order cannot be written, rather than merely documented?
The bulk uploader must be used as initialize(), then configure(options), then run(). Calling run() first uploads to the wrong bucket; calling configure() after run() silently does nothing. Three teams use it and two have shipped the bug.
Document the order. A comment at the top of the class, a line in the README, and a runtime check inside run() that throws if configure has not been called. It is clear, it is cheap, and the exception makes the mistake obvious.
The runtime check catches the ordering people get wrong loudly, and misses the one they get wrong quietly: configure() after run() throws nothing because by then there is nothing to check (Swallowed Errors).
- The runtime check catches the ordering people get wrong loudly, and misses the one they get wrong quietly:
configure()afterrun()throws nothing because by then there is nothing to check (Swallowed Errors). - A conditional call site —
if (isLargeBatch) uploader.configure(bulkOptions)— passes the check on the branch it was tested on and skips configuration on the other. The check made the failure rarer and therefore later. - The ordering knowledge lives in the caller. When someone extracts a helper and moves
initialize()into a shared setup, the sequence breaks in a way neither file shows (Local Reasoning). - The comment ages. Two more phases arrive —
authenticate()andselectRegion()— and now the document describes a five-step ordering with two conditional steps that nobody has verified is even accurate (Documentation Decay).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The uploader is already public inside the monorepo with about thirty call sites, so the fix has to coexist with the old shape for a release or two (Expand and Contract).
- One consumer configures conditionally in a branch, so any redesign has to keep conditional configuration expressible.
- The team is in TypeScript. Encoding order in types is possible but every construct used has to be understandable by someone who has not read a type-level programming article.
- No upload ever runs against an unconfigured destination — not by mistake, not in a code path added next year, not in a test helper.
- Any object that exists is in a state where every method on it is meaningful. A method that is a no-op in the current state is a design defect, not a convenience.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The uploader owns knowing which operations are legal when. Delegating that to the caller is delegating a rule to the party least able to enforce it.
- The caller owns supplying a destination and a batch. It owns no knowledge of phases.
- The type system, where the language allows, owns rejecting the invalid sequence — because it is the only participant that never forgets and never leaves the company.
- The seam is between "configured" and "not configured", and it should be a boundary between two *types*, not two moments in one object's life (Making Illegal States Unrepresentable).
- Each type exposes only the operations legal in that state, so the surface area is the state machine (Designing a Module Interface).
- Where the ordering genuinely cannot be encoded — a protocol driven by external events — the boundary moves to an explicit state field with a single transition function, and the check becomes exhaustive rather than ad hoc (State Machines).
The machine, including the transitions that must not exist
Temporal coupling is a state machine that somebody declined to write down. Writing it down is most of the work, and the forbidden list is the part that turns a diagram into a specification — a transition table without it describes what should happen and says nothing about what must not.
Notice that two of the forbidden transitions fail silently in the original design. Those are the expensive ones: a loud failure is a bug report, a silent one is a support ticket six weeks later about files in the wrong bucket (Swallowed Errors).
| From | On | To | Guard | Effect |
|---|---|---|---|---|
| Unconfigured | configure(options) | Configured | destination resolves and credentials validate | returns a ConfiguredUploader; the unconfigured value is consumed |
| Configured | run(batch) | Running | batch is non-empty | opens the multipart session |
| Running | all chunks acknowledged | Completed | — | finalises the object and emits an upload-completed event |
| Running | chunk error or timeout | Failed | — | aborts the multipart session so no partial object is visible (Partial Failure) |
| Failed | retry() | Running | the failure was transient and the batch is unchanged | starts a new session with a fresh idempotency key (Idempotency by Design) |
| Completed | run(nextBatch) | Running | — | the configured uploader is reused; configuration is unchanged |
- Unconfigured → Running — This is the original bug.
run()beforeconfigure()uploaded to a default bucket — it did not fail, it succeeded against the wrong destination, which is why nobody noticed for a month. - Running → Configured — Reconfiguring mid-flight. In the old design
configure()during a run returned normally and changed nothing, so the caller believed a chunk size that was not in effect. Silent, and it made every latency measurement wrong. - Completed → Completed — Re-running the same batch without a new idempotency key duplicates objects. Making this unrepresentable is cheaper than deduplicating afterwards (Idempotent Is a Property of the Whole Effect, Not the Write).
- Failed → Completed — Marking a failed batch complete because the last chunk eventually acknowledged. The partial upload is still visible unless the session was aborted, so "complete" would be a lie the rest of the system trusts.
The forbidden list is where the ordering requirement actually lives. Everything the original comment said — "call configure first" — is one row of it, and the other three were never written down anywhere.
Make the wrong order unwritable
Once the machine is explicit, the design question is narrow: can the language reject the forbidden transitions, and at what cost in readability? In TypeScript the answer for this shape is yes and the cost is one extra type name.
The move is small — each transition returns a different type, and the previous type has no run on it. There is no cleverness, no phantom generic, and a reader who has never heard the word typestate can follow it.
1// Ordering in a comment. Thirty call sites, two shipped bugs.2class BulkUploader {3 /** Call initialize(), then configure(), then run(). */4 initialize(): void { /* ... */ }5 configure(o: Options): void { /* no-op if already running */ }6 run(batch: Batch): Promise<Result> { /* uses default bucket if unconfigured */ }7}8 9// Ordering in the types. The wrong sequence does not compile.10export function uploader(): Unconfigured {11 return { configure }12}13 14interface Unconfigured { configure(o: Options): Configured }15interface Configured { run(batch: Batch): Promise<Running> }16interface Running { result(): Promise<Result<Completed, Failed>> }17 18// call site — there is no run() to call until configure() has been called19const done = await uploader().configure(opts).run(batch)The conditional case is the one to check before committing to this, because it is where the encoding usually breaks: isLarge ? base.configure(bulk) : base.configure(std) still works because both branches produce the same type, but if (isLarge) base.configure(bulk) — configure sometimes — does not, and cannot. That is the design telling you an unconfigured uploader was never meant to be runnable; if a genuine default exists, the honest fix is a default in uploader() rather than an optional phase (Optional Values and Absence).
Where the ordering is not yours to control
Typestate works when the caller drives the sequence. It stops working when the environment does — a socket that can drop, a device that can be unplugged, a payment terminal that can be cancelled by the customer mid-transaction. There the object cannot hand you a new type on every transition, because the transition happens without a call.
The right design there is not a retreat to comments. It is one explicit state field, one transition function, and an exhaustive switch so a new state produces a compile error rather than a fall-through (State Machines).
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A required setup call is skipped on one branch | Correct behaviour in tests and staging; wrong destination or default config in production on the untested path | The ordering is knowledge in the caller, and the caller has two paths | Make the configured object a distinct type, or fold configuration into construction so there is no unconfigured object to hold |
| A phase is called after it can take effect | No error at all; a setting the caller believes is in effect quietly is not | The method is legal on the object in every state, so "too late" has no representation | Freeze the value at transition time and make the late call a compile error or an explicit failure — never a no-op (Swallowed Errors) |
| Someone extracts a helper and moves one call into shared setup | A different consumer breaks, in a file the refactorer never opened | The sequence spanned files, so no single file showed it was a sequence | Return the next state from each transition, so moving a call moves its result and the compiler follows (Local Reasoning) |
| The environment ends the sequence — disconnect, cancel, timeout | An object stuck in a state whose methods all throw, or worse, all succeed meaninglessly | Typestate assumed the caller drives every transition; here it does not | Explicit state field, one transition function, exhaustive handling of every state including the ones only the environment can cause (Designing for Failure) |
| A new phase is added by someone who does not know the whole sequence | A fifth step documented in a fifth place, and two consumers that never call it | The sequence has no single definition, so additions are appended rather than integrated | One state machine, in code, that a new phase has to be added to before it can be used (Docs Close to Code) |
How to build it
Most important first.
- Draw the state machine first, including the transitions that must not exist. The forbidden list is the requirement (Invalid Transitions).
- Make each state a distinct type and have transitions return the next type. An unconfigured uploader simply has no
runmethod to call (Making Illegal States Unrepresentable). - Collapse the phases if they do not need to be separate: if
initializeandconfigurealways happen together, one constructor taking the options removes the ordering problem entirely and is the better answer whenever it fits. - Where a phase is genuinely optional, give it a default rather than a required call — an optional step nobody can forget is better than a documented one they can (Optional Values and Absence).
- If the object must be long-lived and event-driven, keep one explicit state field, one transition function, and a compile-time exhaustive switch — not a scattering of booleans (Boolean Flag Explosion).
- Keep a runtime guard as well when the language cannot enforce the order across a serialization boundary; belt and braces is right here because the failure is silent (Enforcing Invariants).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Adding a fourth phase —
selectRegion()— costs one new type and a compiler error at every call site that must now supply a region. The compiler produces the migration list; the work is mechanical and complete. - Under the documented-order design, the same change costs a comment update, thirty call sites nobody enumerates, and a bug in whichever consumer configures conditionally.
- What did not get cheaper: changing the *meaning* of a phase — configuration now needing credentials — still crosses every call site, because that is a contract change rather than an ordering one.
- And the ongoing cost: every future phase must be expressible in the type encoding. A protocol that becomes genuinely dynamic will eventually break the encoding, and unwinding it is more work than unwinding a comment.
- Typestate makes conditional and dynamic sequences awkward, and awkwardness in a common case is how escape hatches get added (Optional Values and Absence).
- More types means more names, more files and a steeper first hour for a new reader — who now has to learn
UnconfiguredUploader,ConfiguredUploaderandRunningUploadto do one thing. - Collapsing the phases into a constructor, which is usually the best answer, gives up the ability to reuse a configured uploader across batches — a real cost if configuration is expensive (Allocation and Copies).
What can go wrong
- The types are introduced and one escape hatch remains — a
getRawUploader()for an awkward test — and the invariant is back to being a convention (Exposing Too Much). - The typestate encoding gets clever: phantom types, conditional types, four generic parameters. The ordering is now enforced and nobody on the team can add a phase (The Complexity Budget).
- The states are encoded but the transitions are not exhaustive, so a new state is added and three switches silently fall through to the default (State Machines).
- The mitigation fails on its own terms: splitting into types makes conditional configuration awkward, so a caller reaches for
as anyon the branch that needed it — reintroducing exactly the path that was failing before.
- Thirty call sites depend on the uploader's sequence today; after the change they depend on its types, which the compiler checks for them.
- The dependency moved from documentation to the type system, which is the cheapest possible place for a dependency to live.
- The uploader itself gains no dependencies — this is a shape change, not a new collaborator, which is what makes it feasible to do incrementally.
- "Add a runtime check and it is fixed." A check turns a silent failure into a loud one on the paths that execute. It does nothing for the conditional path, and nothing at all for the transition whose failure mode is doing nothing (Enforcing Invariants).
- "So never use builders." Builders are fine; builders where an incomplete object is usable are not. A builder whose
build()is the only way to get a usable object has no temporal coupling at all (Factory). - "This is just about initialization." Any required ordering is temporal coupling: acquire before release, begin before commit, subscribe before publish, validate before persist. Initialization is only the most familiar case (Where the Transaction Boundary Goes).
- "Encode everything in types." In a language without the facilities, or for a sequence driven by external events, the honest answer is an explicit state field with exhaustive transitions — and that is a good design, not a fallback (State Machines).
- boolean-flag-explosion
- long-parameter-list
Testing it, and how it ages
- The best test here is a compile failure. A type-level test — a snippet asserted not to compile — is the check that the ordering is enforced (Testing as Design Feedback).
- Where the order is enforced at runtime instead, test each forbidden transition explicitly, including the silent ones: assert that
configureafterrunfails rather than doing nothing (Invalid Transitions). - Test the conditional path specifically. The bug in this lesson lives on the branch that was not exercised (Property-Based Testing).
- Do not test that the happy sequence works and call it covered; the whole failure class is in the sequences nobody wrote a test for.
- Phases accumulate. Every uploader, client and builder that lives long enough grows an authentication step, a region step and a retry-policy step, and each is added by someone who does not know the whole sequence (API Stability).
- The typed version ages well precisely because each addition is a compiler-enforced migration rather than a documentation update.
- It stops being right when the protocol becomes externally driven — a device that can be disconnected at any point — because then the state is not under the caller's control and the encoding must move to an explicit machine with a runtime state field (State Machines).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat an undocumented required ordering is knowledge living outside the code holds anywhere; what differs is whether the language can reject the wrong order, which changes the fix rather than the diagnosis.
- LANGUAGE-SPECIFICRust's ownership makes typestate natural and cheap — each transition consumes the previous value, so an unconfigured uploader cannot be used after
configureand cannot be used before it either. In TypeScript the same encoding is possible but only advisory, since a cast defeats it; in Python it is not practically expressible and the honest design is an explicit state attribute plus a guard on every method. - CONTESTEDThe strongest opposing view is that typestate encodings optimise for a mistake that a single well-placed runtime assertion plus one test would catch, while imposing a permanent readability cost on everyone — and that in most codebases the sequence is called from a handful of places, all reviewed. That is persuasive when the object is constructed in one place; it weakens sharply when the sequence is conditional or spread across files, which is exactly when the bug actually occurs.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — whether an ordering can be enforced at compile time is a property of the type system, and linear or affine types are the feature that makes typestate cheap rather than clever.