Skip to main content
Type-Driven Architecture

From TypeScript to TLA+: Using Type-Level Programming to Model Concurrent System Protocols

You have a TypeScript codebase with deeply nested generics, conditional types that encode state machines, and mapped types that enforce protocol transitions at compile time. It works — until you add concurrency. Suddenly, type-level guarantees don't catch interleaving bugs, deadlocks, or livelocks. This is where TLA+ enters. We will show how to leverage your existing type-level thinking to model concurrent protocols in TLA+, preserving the same algebraic rigor while gaining the ability to verify temporal properties. Why Type-Level Modeling Hits a Wall with Concurrency TypeScript's type system is Turing-complete, and teams have used it to encode finite state machines, protocol state transitions, and even simple linearizability checks. But types check one execution path at a time. When two actors interact asynchronously, the number of interleavings explodes, and the type checker cannot enumerate them.

You have a TypeScript codebase with deeply nested generics, conditional types that encode state machines, and mapped types that enforce protocol transitions at compile time. It works — until you add concurrency. Suddenly, type-level guarantees don't catch interleaving bugs, deadlocks, or livelocks. This is where TLA+ enters. We will show how to leverage your existing type-level thinking to model concurrent protocols in TLA+, preserving the same algebraic rigor while gaining the ability to verify temporal properties.

Why Type-Level Modeling Hits a Wall with Concurrency

TypeScript's type system is Turing-complete, and teams have used it to encode finite state machines, protocol state transitions, and even simple linearizability checks. But types check one execution path at a time. When two actors interact asynchronously, the number of interleavings explodes, and the type checker cannot enumerate them. A type-level state machine might enforce that a connection transitions from 'open' to 'closed' only after 'draining', but it cannot prove that two concurrent senders never corrupt a shared buffer. TLA+ was designed precisely for this: modeling all possible behaviors of a concurrent system and checking invariants across every interleaving. The shift is not abandoning types but extending the same discipline — state variables, transitions, invariants — into a specification language built for concurrency.

What You Already Know That Transfers Directly

If you have written a discriminated union to represent protocol states and used conditional types to restrict transitions, you already understand TLA+'s core concepts. A TypeScript type like type State = 'idle' | 'connecting' | 'open' | 'closed' maps to a TLA+ VARIABLE state with a similar set of values. A transition function in TypeScript that returns the next state given an action becomes a TLA+ Next action formula. The invariant you enforce with a type guard — e.g., assert(state !== 'closed' || buffer.length === 0) — becomes a TLA+ invariant checked by the TLC model checker against all reachable states. The main difference: TLA+ formulas are untyped and checked at model-checking time, not compile time. This gives you the freedom to model nondeterminism (e.g., a message may arrive or be lost) without fighting the type system.

When to Reach for TLA+ Instead of More Types

Not every concurrent pattern needs TLA+. If your protocol has at most two participants and a handful of states, a type-level encoding with exhaustive switch statements and runtime assertions may suffice. But consider: a distributed lock manager with lease renewal, a multi-phase commit across three nodes, or a rate limiter that must tolerate clock skew. In these cases, the number of interleavings grows combinatorially, and type-level checks miss temporal safety properties like 'no two nodes hold the lock simultaneously' or 'every request eventually receives a response'. TLA+ lets you express liveness and fairness constraints — properties that are impossible to encode in TypeScript's type system. A good rule of thumb: if you find yourself writing comments like '// invariant: only one writer at a time' or '// assume messages arrive in order', you have a candidate for TLA+ specification.

Setting Up Your TLA+ Environment for TypeScript Developers

The TLA+ ecosystem includes the TLA+ Toolbox (IDE), the TLC model checker, and the PlusCal algorithm language. For developers comfortable with VS Code, the TLA+ Nightly extension provides syntax highlighting and inline error checking. Start by installing the TLA+ Toolbox from the official GitHub releases — it bundles TLC and a graphical interface for running models. Alternatively, use the command-line tools: tla2tools.jar for parsing and TLC for checking. A typical workflow: write a .tla file, define constants and variables, specify Init and Next, write invariants, and run TLC to check them. The learning curve is steep for the first hour, but the conceptual overlap with type-level programming flattens it considerably.

Translating a TypeScript State Machine to TLA+

Consider a simple WebSocket connection manager. In TypeScript, you might have:

type State = 'disconnected' | 'connecting' | 'connected' | 'closing';
type Event = { type: 'CONNECT' } | { type: 'OPEN' } | { type: 'CLOSE' } | { type: 'ERROR' };
function transition(state: State, event: Event): State { ... }

In TLA+, the same logic becomes:

VARIABLE state
Init == state = 'disconnected'
Next == \/ state = 'disconnected' /\' state' = 'connecting'
        \/ state = 'connecting' /\' state' = 'connected'
        \/ state = 'connected' /\' state' = 'closing'
        \/ ...

The Next formula is a disjunction of possible transitions — exactly like a discriminated union but with the ability to add guards (e.g., a message buffer must be empty before closing). The model checker will explore every combination of transitions, including those where two events occur simultaneously (if you model concurrency as interleaving). This is something TypeScript cannot do.

Running Your First Model Check

After writing the spec, define an invariant like Invariant == state /= 'connected' \/ bufferLen <= 10 and run TLC. The checker will report whether the invariant holds for all reachable states. If it finds a violation, it produces a trace — a sequence of states leading to the error. Reading traces is similar to debugging a complex type error: you step through each transition, but now you see the full interleaving history. Start with small models (few variables, small sets) to keep checking times under a minute. As you gain confidence, increase the model's scope.

Core Workflow: From TypeScript Types to TLA+ Specs

The translation workflow has four phases: (1) identify the concurrent protocol boundary, (2) extract state variables and actions from the TypeScript types, (3) write the TLA+ spec with Init and Next, (4) define invariants and run TLC. Let's walk through each with a concrete example: a simple leader election protocol. In TypeScript, you might model a node as an object with fields role: 'follower' | 'candidate' | 'leader' and term: number. The type system can enforce that a node only votes once per term, but it cannot check that at most one leader exists globally across all nodes. That is a safety invariant best expressed in TLA+.

Step 1: Define Constants and Variables

Start with the set of nodes: CONSTANTS Nodes. Then variables per node: VARIABLE role (function from node to role), VARIABLE term (function from node to natural number), VARIABLE votedFor (function from node to node or None). The TypeScript type Map<string, Role> becomes a TLA+ function [Nodes -> {"follower", "candidate", "leader"}]. This explicit mapping of state space is the biggest shift: TLA+ makes the global state visible, whereas TypeScript types only see local state.

Step 2: Write Init and Next

Init == sets every node to follower, term 0, votedFor None. Next is a disjunction of actions: BecomeCandidate(node), Vote(node, voter), BecomeLeader(node). Each action updates the relevant variables. For example, BecomeCandidate(node) == role[node] = "follower" /\ role' = [role EXCEPT ![node] = "candidate"] /\ term' = [term EXCEPT ![node] = term[node] + 1] /\ UNCHANGED votedFor. The EXCEPT construct is TLA+'s functional update — analogous to the spread operator in TypeScript but with precise semantics.

Step 3: Define Invariants and Check

The key invariant: Safety == \A n1, n2 \in Nodes : (role[n1] = "leader" /\ role[n2] = "leader") => n1 = n2. Also check that no node votes twice in the same term: VoteOnce == \A n \in Nodes : \A t \in Nat : .... Run TLC with a small model (e.g., 3 nodes, max term 2). If it finds a violation, examine the trace. You may discover a scenario where two nodes become leader due to message loss — exactly the kind of bug that type-level checks miss.

Tools and Setup Realities for the TypeScript Developer

The TLA+ toolchain is mature but has a different feel than modern JS tooling. The TLA+ Toolbox is an Eclipse-based IDE; while functional, its UI is dated. Many developers prefer writing specs in VS Code with the TLA+ extension, then running TLC from the command line. The PlusCal variant (a C-like algorithm language) compiles to TLA+ and is easier for imperative thinkers. For TypeScript developers, PlusCal's algorithm blocks with while loops and await (modeled as with statements) feel familiar. However, PlusCal abstracts away some of TLA+'s temporal operators, so pure TLA+ is better for liveness properties. We recommend starting with PlusCal for simple protocols and graduating to TLA+ when you need fairness or temporal logic.

Model Checking Performance

TLC enumerates all reachable states. For small models (3–5 nodes, limited variable ranges), checking completes in seconds. As you add nodes or unbounded integers, state space explodes. Use symmetry sets (Symmetry in TLC config) to reduce checking time when nodes are indistinguishable. Also, use Invariant to prune unreachable states early. A common mistake is to include too many variables; start with the minimal set needed to express the invariant. You can always add detail later.

Integration with TypeScript Code Generation

There is no automatic translation from TypeScript types to TLA+ — the abstraction levels differ too much. However, you can use the TLA+ spec as a source of truth to generate runtime checks in TypeScript. For example, after verifying an invariant in TLA+, add an assert in your TypeScript code that mirrors the invariant. This creates a feedback loop: the spec guides implementation, and runtime assertions catch deviations. Some teams write TLA+ specs alongside type definitions in a monorepo, treating the .tla file as a design document that evolves with the code.

Variations for Different Constraints

Not every protocol demands full TLA+. If your system is single-threaded but asynchronous (e.g., an event loop with promises), a type-level state machine plus runtime assertions may be sufficient. For distributed systems with unreliable communication, TLA+ is almost mandatory. Another variation: use TLA+ to model the protocol's failure modes (e.g., crash, network partition) and then implement a simpler version in TypeScript that handles only the normal case, relying on the spec to guide error handling. For systems with real-time constraints (e.g., timeouts), TLA+ can model discrete time steps, but you need to be careful about state explosion. An alternative is to model only the logical protocol and treat time as an external event.

When to Use PlusCal vs. Raw TLA+

PlusCal is ideal for developers who think algorithmically. It compiles to TLA+, so you can still check invariants. However, PlusCal's fair and either constructs are less expressive than TLA+'s temporal formulas. For liveness properties like 'every request eventually gets a response', raw TLA+ with <>[] (eventually always) is clearer. Our recommendation: use PlusCal for the initial spec, then rewrite critical parts in TLA+ if you need to verify liveness. The PlusCal-to-TLA+ translation is transparent, so you can always inspect the generated TLA+ to learn.

Combining TypeScript Types with TLA+ Specs

A practical pattern: define your protocol's state and actions as TypeScript types (for compile-time safety in the implementation) and then manually write a TLA+ spec that mirrors them. The types serve as documentation and runtime guards; the TLA+ spec serves as the ground truth for concurrency properties. To keep them in sync, add a comment in the TypeScript code referencing the TLA+ spec file. Some teams write a script that extracts type definitions and generates a TLA+ skeleton, but this is rare. The manual translation, while tedious, forces you to think about the global state — which is the whole point.

Pitfalls, Debugging, and What to Check When It Fails

The most common pitfall when moving from TypeScript to TLA+ is over-specification. Developers try to encode every implementation detail (e.g., exact buffer sizes, message IDs) and end up with a spec that is too large to model-check. Start with an abstract model: ignore message ordering, assume synchronous communication initially, and only add complexity when the checker finds no violations. Another pitfall: confusing TLA+'s = (assignment) with = (equality). In TLA+, x' = x + 1 means the next value of x equals the current plus one — it is a formula, not an imperative assignment. This trips up many newcomers. Debugging a failed invariant involves reading the trace produced by TLC. Traces show the sequence of states; look for the first state where the invariant is false. Often the bug is a missing guard in the Next action (e.g., allowing a transition when the state is not ready).

Common Invariant Violations

The most frequent violations are safety violations: two nodes in a mutually exclusive state, or a counter exceeding a bound. Liveness violations (e.g., starvation) are harder to catch because they require checking that something eventually happens. To check liveness, you need to specify fairness assumptions (e.g., WeakFairness on actions) and use TLC's liveness checking mode, which is slower. Start with safety invariants only; add liveness after the safety checks pass. Another common issue: using UNCHANGED incorrectly. If you forget to specify that a variable stays unchanged in an action, TLC will consider any value legal, leading to false violations. Always list all variables in each action, either by updating them or using UNCHANGED.

When the Model Checker Times Out

If TLC runs out of memory or time, reduce the state space. Use smaller constant sets (e.g., 2 nodes instead of 5), limit integer ranges, or add symmetry sets. You can also use the View option in TLC to hash states and avoid storing full states. Another technique: decompose the spec into smaller sub-specs that check individual invariants. For example, check safety with a small model, then check liveness with an even smaller model. If the spec still times out, the protocol may be too complex for exhaustive checking; consider using a different method (e.g., runtime verification or testing with random interleavings).

Frequently Asked Questions and Common Mistakes

Q: Do I need to learn all of TLA+ to model concurrent protocols? No. You need only the subset: variables, Init, Next, invariants, and basic temporal operators. The TLA+ book is comprehensive, but for TypeScript developers, the PlusCal tutorial is a faster entry. Q: Can TLA+ replace unit tests? No. TLA+ checks the model, not the implementation. You still need tests for implementation bugs. However, TLA+ can reduce the number of concurrency-related bugs that reach production. Q: How do I model network failures? Add a variable representing message delivery (e.g., VARIABLE delivered as a set of messages) and nondeterministically choose whether a sent message is delivered or lost. Use \/ to model both possibilities. Q: What is the biggest mistake beginners make? Writing a spec that is too detailed. They try to model every field of a TypeScript object, leading to state explosion. Abstract to the minimal state needed to express the invariant. Q: Can I use TLA+ for single-threaded async code? Yes, but it is often overkill. For a single event loop, type-level state machines with runtime checks are usually sufficient. Reserve TLA+ for when multiple actors or nondeterministic inputs are involved.

Checklist Before Running TLC

  • Have you defined all variables that appear in Init and Next?
  • Does every action update all variables (or use UNCHANGED)?
  • Are constants bounded to small values for the first run?
  • Is the invariant a boolean formula (no side effects)?
  • Have you run the spec through the TLA+ parser to catch syntax errors?

Next Steps: From Spec to Production

After you have a verified TLA+ spec, the next step is to implement the protocol in TypeScript, using the spec as a blueprint. Write runtime assertions that mirror the invariants — they serve as a safety net during development. Then, consider fuzz testing the implementation with random interleavings (e.g., using a library like fuzz to reorder async operations). The combination of TLA+ verification and runtime checks gives you confidence that the implementation matches the model. Finally, share the spec with your team as a design document. TLA+ specs are more precise than natural language and less ambiguous than type definitions. Over time, you can build a library of reusable TLA+ modules for common patterns (leader election, consensus, rate limiting). The investment in learning TLA+ pays off when you catch a subtle concurrency bug that would have taken weeks to reproduce in testing. Start with a small protocol, run the model checker, and let the traces guide your understanding. The type-level mindset you already have is the perfect foundation.

Share this article:

Comments (0)

No comments yet. Be the first to comment!