TypeScript: a process lifecycle as a typed state machine
Allow only legal transitions between process states and reject the rest.
type State = "new" | "ready" | "running" | "waiting" | "terminated";
const transitions: Record<State, State[]> = {
new: ["ready"],
ready: ["running"],
running: ["ready", "waiting", "terminated"],
waiting: ["ready"],
terminated: [],
};
function move(from: State, to: State): State {
if (!transitions[from].includes(to)) {
throw new Error("illegal transition: " + from + " -> " + to);
}
return to;
}
let state: State = "new";
for (const next of ["ready", "running", "waiting", "ready", "running", "terminated"] as State[]) {
const before = state;
state = move(state, next);
console.log(before + " -> " + state);
}
try {
move("waiting", "running");
} catch (error) {
console.log((error as Error).message);
}new -> ready ready -> running running -> waiting waiting -> ready ready -> running running -> terminated illegal transition: waiting -> running
Explanation
An operating system tracks each process through states. A new process becomes ready, the scheduler dispatches it to running, a running process may be preempted back to ready, blocked on I/O in the waiting state, or finish as terminated. A waiting process becomes ready again when its I/O completes, but it cannot jump straight to running.
A string-literal union type lists the legal states, so a typo in a state name is a compile error. The Record<State, State[]> table gives the legal next states for each state, and the compiler insists that every state has an entry. The move function enforces the table at run time and throws on an illegal transition.
Explore the dry run
type State = "new" | "ready" | "running" | "waiting" | "terminated"; const transitions: Record<State, State[]> = { new: ["ready"], ready: ["running"], running: ["ready", "waiting", "terminated"], waiting: ["ready"], terminated: [],}; function move(from: State, to: State): State { if (!transitions[from].includes(to)) { throw new Error("illegal transition: " + from + " -> " + to); } return to;} let state: State = "new";for (const next of ["ready", "running", "waiting", "ready", "running", "terminated"] as State[]) { const before = state; state = move(state, next); console.log(before + " -> " + state);} try { move("waiting", "running");} catch (error) { console.log((error as Error).message);}Follow the example
Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.
View the complete dry-run table
| Step / line | What happens | State after step | Output so far |
|---|---|---|---|
| Only these five strings are valid states. | — | — | |
| Every state must appear exactly once, or the compiler reports an error. | — | — | |
| Each step is checked against the table before it is applied. | — | — | |
| The process waits for I/O once, then returns to ready and finishes. | — | new -> ready ready -> running running -> waiting waiting -> ready ready -> running running -> terminated | |
| A waiting process must go through the ready queue first, so this throws. | — | new -> ready ready -> running running -> waiting waiting -> ready ready -> running running -> terminated | |
| The error message names the illegal transition. | — | new -> ready ready -> running running -> waiting waiting -> ready ready -> running running -> terminated illegal transition: waiting -> running |
Cost and common mistakes
O(1) per transition (a lookup in a small array).
- The cast as State[] is needed because a plain array literal of strings would be inferred as string[].
- A table-driven design is easier to check than a pile of if statements. Adding a state forces you to update the table.
- Real kernels have more states (such as suspended and zombie) and per-process control blocks.
Try it yourself
Which transition does the scheduler perform to give the CPU to a ready process?
Reveal answer and reasoning
ready -> running, which is called dispatching the process.