🚀 Executive Summary
TL;DR: Traditional string-based state management in TypeScript leads to brittle code and runtime errors due to scattered logic and lack of compile-time validation. The doeixd/machine library introduces Type-State Programming, where states are types, not strings, enabling compile-time safety and eliminating entire classes of runtime state errors by making invalid transitions impossible to write.
🎯 Key Takeaways
- Stringly-typed state management results in runtime errors, scattered logic, high maintenance overhead, and weak public contracts, as the compiler offers no protection against invalid state transitions.
- Traditional state machine libraries like XState centralize state logic and offer robust runtime safety, but their core enforcement is still a runtime concern, meaning invalid events can be sent (though safely ignored).
- The Type-State Pattern, exemplified by
doeixd/machine, shifts state validation to compile-time by making an object’s state part of its TypeScript type, thereby exposing only valid methods and preventing invalid transitions at compilation. - Using
doeixd/machineeliminates the need for defensive runtime guard clauses, makes code self-documenting through type definitions, and allows for confident refactoring of stateful logic. - Type-State Programming is best suited for critical business logic with well-defined lifecycles where the cost of runtime state errors is high, ensuring correctness before execution.
Discover how Type-State Programming with TypeScript libraries like doeixd/machine can eliminate entire classes of runtime state errors by moving state validation from runtime checks to compile-time guarantees, resulting in more robust and maintainable systems.
Eliminating Runtime State Errors with Compile-Time Guarantees
As DevOps engineers, we manage complex systems with distinct lifecycles: a CI/CD pipeline, an infrastructure provisioning request, or a user session. We often represent these lifecycles using state machines. However, the common approach of using simple strings or enums to track state is a ticking time bomb, leading to brittle code and elusive runtime errors. This post explores a more robust pattern that leverages the TypeScript compiler to make invalid states and transitions literally impossible to write.
The Symptoms: The Perils of String-Based State Management
You’ve likely seen or written code like this. It seems harmless at first, but it’s a primary source of production incidents. Consider a simplified CI/CD job object:
class CiJob {
// The dreaded 'stringly-typed' state
status: 'pending' | 'running' | 'success' | 'failed';
constructor() {
this.status = 'pending';
}
run() {
if (this.status !== 'pending') {
throw new Error(`Cannot run job in state: ${this.status}`);
}
console.log('Job is now running...');
this.status = 'running';
}
reportSuccess() {
// What if a developer forgets this check?
if (this.status !== 'running') {
throw new Error(`Cannot succeed job in state: ${this.status}`);
}
console.log('Job succeeded.');
this.status = 'success';
}
}
This pattern exhibits several painful symptoms:
-
Runtime Errors: If a developer calls
job.reportSuccess()while the job is still'pending', the application throws a runtime error. The compiler offered no protection. -
Scattered Logic: State transition logic is spread across multiple methods, each requiring defensive guard clauses (
if (this.status !== ...)). Understanding the complete state lifecycle requires reading the entire class. -
High Maintenance Overhead: Adding a new state (e.g.,
'queued') requires a careful audit of every single method to update the conditional logic, which is a highly error-prone process. -
Weak Contracts: The public interface of the
CiJobclass falsely suggests that any method can be called at any time. The true contract is hidden within runtime checks.
Three Approaches to Taming State in TypeScript
Let’s evaluate three distinct methods for managing state, moving from the most common and brittle to the most robust and type-safe.
Solution 1: The Ad-Hoc “Stringly-Typed” Method
This is the pattern described in our “Symptoms” section. It involves using a property on an object (typically a string or an enum) to track its current state. All transition logic is handled manually within the object’s methods.
Example:
const job = new CiJob();
// job.run(); // This works
// job.reportSuccess(); // This throws a runtime error. Oops.
// The developer has to know the correct sequence.
// The compiler provides no assistance.
if (job.status === 'pending') {
job.run();
}
if (job.status === 'running') {
job.reportSuccess();
}
This approach places the entire burden of correctness on the developer and runtime checks. It’s simple to start but scales poorly and is inherently unsafe.
Solution 2: Centralized Logic with Traditional Libraries like XState
Libraries like XState bring formality and centralization to state management. They are implementations of Statecharts, a formalism for describing complex stateful systems. Instead of scattering logic in methods, you define the entire state machine declaratively.
Example (Simplified XState):
import { createMachine, interpret } from 'xstate';
const ciJobMachine = createMachine({
id: 'ciJob',
initial: 'pending',
states: {
pending: {
on: { RUN: 'running' } // Event 'RUN' transitions to 'running'
},
running: {
on: {
SUCCESS: 'success',
FAIL: 'failed'
}
},
success: {
type: 'final'
},
failed: {
type: 'final'
}
}
});
const jobService = interpret(ciJobMachine).start();
console.log(jobService.getSnapshot().value); // 'pending'
jobService.send({ type: 'RUN' });
console.log(jobService.getSnapshot().value); // 'running'
// Sending an invalid event for the current state does nothing.
jobService.send({ type: 'RUN' }); // No state change, still 'running'
console.log(jobService.getSnapshot().value); // 'running'
XState is a massive improvement. It centralizes all possible states and transitions, making the system’s behavior explicit and predictable at runtime. While its TypeScript support is excellent, the core enforcement is still a runtime concern—the compiler won’t stop you from calling jobService.send({ type: 'AN_INVALID_EVENT' }), though XState will safely ignore it.
Solution 3: The Type-State Pattern with doeixd/machine
This approach represents a paradigm shift. Instead of storing state in a value, the state becomes part of the object’s **type**. An object in a Pending state has a different type from an object in a Running state, and therefore exposes different methods. This is where a minimal library like doeixd/machine shines, providing the necessary boilerplate to enable this pattern cleanly.
First, install the library:
npm install @doeixd/machine
Example:
import { createMachine } from '@doeixd/machine';
// 1. Define states as types. Each state can have its own data (`value`).
type States =
| { state: 'pending'; value: { jobId: string } }
| { state: 'running'; value: { jobId: string; startTime: number } }
| { state: 'success'; value: { jobId: string; reportUrl: string } }
| { state: 'failed'; value: { jobId: string; error: string } };
// 2. Define transitions. The functions receive the current state's value.
const transitions = {
run: (s: { state: 'pending'; value: { jobId: string } }) => ({
state: 'running' as const, // The 'as const' is crucial
value: { ...s.value, startTime: Date.now() },
}),
succeed: (s: { state: 'running'; value: { jobId: string; startTime: number } }) => ({
state: 'success' as const,
value: { ...s.value, reportUrl: `https://ci.example.com/job/${s.value.jobId}` },
}),
fail: (s: { state: 'running'; value: { jobId: string; startTime: number } }) => ({
state: 'failed' as const,
value: { ...s.value, error: 'Build step failed' },
}),
};
// 3. Create the machine instance
let job = createMachine<States>({
state: 'pending',
value: { jobId: 'abc-123' }
}, transitions);
// `job.state` is typed as 'pending'.
console.log(job.state);
// 4. Perform a valid transition.
// The `transition` method only shows available transitions for the current state.
// Autocomplete will suggest `run`, but not `succeed` or `fail`.
job = job.transition('run');
// The type of `job` has now changed!
// `job.state` is now typed as 'running'.
// `job.value` now has a `startTime` property.
console.log(job.state, job.value.startTime);
// 5. This line will cause a COMPILE-TIME error!
// Property 'run' does not exist on type 'Machine<...>'.
// The only available transitions are 'succeed' and 'fail'.
// job = job.transition('run');
// This is a valid transition from the 'running' state.
job = job.transition('succeed');
console.log(job.state, job.value.reportUrl);
With this pattern, the TypeScript compiler becomes your state guardian. It’s impossible to call a transition that isn’t valid for the current state. There’s no need for defensive if statements or runtime checks for state logic—the type system guarantees correctness before your code is ever executed.
Comparing the Approaches
Let’s summarize the differences in a more direct comparison:
| Feature | Ad-Hoc (Stringly-Typed) | Traditional (XState) | Type-State (doeixd/machine) |
|---|---|---|---|
| Compile-Time Safety | None. All checks are at runtime. | Good type inference, but core logic is runtime-based. You can still send invalid events. | Excellent. Invalid transitions or actions are compiler errors. |
| Runtime Safety | Depends entirely on developer discipline. Prone to errors. | Excellent. The machine robustly handles invalid events and guarantees state integrity. | Excellent. Correctness is enforced at compile time, leading to fewer possible runtime failures. |
| Logic Centralization | Poor. Logic is scattered across methods. | Excellent. The entire state machine is defined in one declarative structure. | Very Good. Transitions are defined together, but state-specific data is part of the type definition. |
| Verbosity / Boilerplate | Low initial boilerplate, but high repetitive boilerplate (guard clauses). | High. The declarative configuration can be verbose for complex machines. | Medium. Requires defining states as types and transition functions, but is often more concise than XState. |
| Best For | Simple components with 2-3 trivial states. Not recommended for critical logic. | Complex, intricate systems with many states, guards, and side effects (actors). Great for UI components. | Critical business logic with a well-defined, strict lifecycle (e.g., order fulfillment, infrastructure resources). |
Conclusion: When to Use the Type-State Pattern
The Type-State Programming pattern, enabled by libraries like doeixd/machine, is not a replacement for all other forms of state management. However, for core business logic and system workflows where correctness is paramount, it is a game-changer.
Adopt this pattern when:
- The cost of a runtime state error is high (e.g., incorrect billing, failed infrastructure provisioning).
- The lifecycle of an object is well-defined and critical to your application’s logic.
- You want to make your code self-documenting; the type definitions clearly express what is possible in any given state.
- You want to refactor stateful code with confidence, knowing the compiler will catch any broken logic paths.
By shifting state validation from runtime to compile time, you eliminate an entire category of bugs, reduce the need for verbose runtime checks, and create systems that are not just more reliable, but also easier to reason about and maintain.
👉 Read the original article on TechResolve.blog
☕ Support my work
If this article helped you, you can buy me a coffee:

Top comments (0)