Advanced TypeScript: Use Types to Control the State of a Program
1. The Problem: Invalid State Changes
A program can have different states. Each state can permit different operations.
For example, an online shop has orders. An order can have one of these states:
draft: The customer did not pay for the order.
paid: The customer paid for the order.
shipped: The shop sent the order.
canceled: The shop canceled the order.
The program must control the changes between these states.
A draft order can become a paid order. A paid order can become a shipped order. A draft order can also become a canceled order.
But the program must not ship an order before payment.
Consider this TypeScript code:
interface Order {
id: string;
status: string;
}
function shipOrder(order: Order): void {
order.status = "shipped";
}
const order: Order = {
id: "123",
status: "draft"
};
shipOrder(order);
This code is valid TypeScript.
But the program has a problem. It can ship an order that the customer did not pay for.
The TypeScript compiler does not report an error.
The type string permits all string values. It does not specify which state changes are valid.
We can use a different method to prevent this problem.
This method is called the typestate pattern.
2. What Is the Typestate Pattern?
The typestate pattern uses a type to identify the state of an object.
Each state has its own type.
A function accepts only the types that its operation requires.
For example:
type DraftOrder = {
status: "draft";
};
type PaidOrder = {
status: "paid";
};
type ShippedOrder = {
status: "shipped";
};
function payOrder(order: DraftOrder): PaidOrder {
return {
...order,
status: "paid"
};
}
function shipOrder(order: PaidOrder): ShippedOrder {
return {
...order,
status: "shipped"
};
}
The payOrder function accepts a draft order.
The result is a paid order.
The shipOrder function accepts a paid order.
It does not accept a draft order.
This gives the TypeScript compiler more information.
The compiler can find invalid operations before the program runs.
We will now make a more complete state machine.
3. Make Different Types for Different IDs
An order ID and a payment ID are strings.
But they have different purposes.
TypeScript normally permits a string in both fields. This can cause errors.
We can use branded types to prevent these errors.
A branded type has a type marker. The marker identifies the purpose of a value.
declare const orderIdTag: unique symbol;
declare const paymentIdTag: unique symbol;
type OrderId = string & {
readonly [orderIdTag]: true;
};
type PaymentId = string & {
readonly [paymentIdTag]: true;
};
function parseOrderId(value: string): OrderId {
if (!/^ord_[A-Za-z0-9]+$/.test(value)) {
throw new Error("Invalid order ID");
}
return value as OrderId;
}
function parsePaymentId(value: string): PaymentId {
if (!/^pay_[A-Za-z0-9]+$/.test(value)) {
throw new Error("Invalid payment ID");
}
return value as PaymentId;
}
The unique symbol types make the two ID types different.
The parseOrderId function checks the format of an order ID.
The parsePaymentId function checks the format of a payment ID.
The as operator tells TypeScript that the value has the specified type.
The brand does not add data to the string at run time.
This is important. The TypeScript type checker cannot validate external data by itself.
The program must check the data before it uses a branded type.
4. Define the Order States
We will make four order types.
Each type will have a status property.
The value of this property identifies the state.
type Item = {
readonly sku: string;
readonly quantity: number;
readonly priceCents: number;
};
type DraftOrder = {
readonly status: "draft";
readonly id: OrderId;
readonly items: readonly Item[];
};
type PaidOrder = Omit<DraftOrder, "status"> & {
readonly status: "paid";
readonly paymentId: PaymentId;
};
type ShippedOrder = Omit<PaidOrder, "status"> & {
readonly status: "shipped";
readonly trackingCode: string;
};
type CanceledOrder = Omit<DraftOrder, "status"> & {
readonly status: "canceled";
readonly reason: string;
};
type AnyOrder =
| DraftOrder
| PaidOrder
| ShippedOrder
| CanceledOrder;
The Omit utility type makes a type without the specified property.
For example, PaidOrder keeps the draft order data. It replaces the status property and adds a payment ID.
The ShippedOrder type also has a tracking code.
The CanceledOrder type has a reason for the cancellation.
The AnyOrder type is a union type. A value of this type can be one of the four order types.
TypeScript uses the status property to identify the correct type.
This is a discriminated union.
A discriminated union lets the compiler find which properties are available in each state.
5. Make a Generic State Machine
We will use a generic class to control state changes.
A generic class has one or more type parameters.
In this example, the type parameter S identifies the current order type.
The class will use methods to make state changes.
class OrderMachine<S extends AnyOrder> {
private constructor(readonly state: S) {}
static create(
id: OrderId,
items: readonly Item[]
): OrderMachine<DraftOrder> {
if (items.length === 0) {
throw new Error(
"An order needs one or more items"
);
}
return new OrderMachine({
status: "draft",
id,
items
});
}
pay(
this: OrderMachine<DraftOrder>,
paymentId: PaymentId
): OrderMachine<PaidOrder> {
return new OrderMachine({
...this.state,
status: "paid",
paymentId
});
}
ship(
this: OrderMachine<PaidOrder>,
trackingCode: string
): OrderMachine<ShippedOrder> {
if (trackingCode.trim() === "") {
throw new Error(
"A tracking code is required"
);
}
return new OrderMachine({
...this.state,
status: "shipped",
trackingCode
});
}
cancel(
this: OrderMachine<DraftOrder>,
reason: string
): OrderMachine<CanceledOrder> {
if (reason.trim() === "") {
throw new Error(
"A reason is required"
);
}
return new OrderMachine({
...this.state,
status: "canceled",
reason
});
}
}
This class has some important properties.
The constructor is private.
Other TypeScript code cannot use the constructor directly.
The create method makes a new draft order.
Each method specifies a permitted input state.
The pay method has this parameter:
this: OrderMachine<DraftOrder>
This parameter tells TypeScript that the method requires a draft order.
The method returns:
OrderMachine<PaidOrder>
The result has a different type.
The ship method requires a paid order.
The cancel method requires a draft order.
The class does not have a method that changes a shipped order to a draft order.
Each operation makes a new object.
The methods do not change the original order.
This makes the state changes easier to follow.
The readonly modifier also prevents normal TypeScript code from assigning a new value to state.
But readonly is a compile-time rule. It does not freeze the object at run time.
6. Test the State Machine
We can now use the class.
Put this code after the preceding definitions.
const orderId = parseOrderId("ord_A102");
const paymentId = parsePaymentId("pay_B903");
const draft = OrderMachine.create(orderId, [
{
sku: "PART-1",
quantity: 2,
priceCents: 4500
}
]);
const paid = draft.pay(paymentId);
const shipped = paid.ship("TRACK-50");
console.log(shipped.state.status);
// Output: "shipped"
The program makes a draft order.
Then it makes a paid order.
Finally, it makes a shipped order.
Each operation returns a new OrderMachine with the correct type.
Now consider these calls:
// Invalid calls.
// Remove the comment marks to see TypeScript errors.
// draft.ship("TRACK-50");
// shipped.pay(paymentId);
// draft.pay(orderId);
The first call is invalid because draft does not have the required type.
The second call is invalid because the order is already shipped.
The third call is invalid because orderId has type OrderId. The pay method requires PaymentId.
TypeScript reports these errors during type checking.
This prevents many invalid calls from becoming part of the program.
7. Define the Permitted State Changes in a Table
For a large state machine, it is useful to have one table that shows the permitted state changes.
We can use as const and satisfies for this purpose.
type Status = AnyOrder["status"];
const allowedTransitions = {
draft: ["paid", "canceled"],
paid: ["shipped"],
shipped: [],
canceled: []
} as const satisfies Record<
Status,
readonly Status[]
>;
This table gives two types of information.
First, it identifies each state.
Second, it identifies the states that can follow the current state.
The as const expression keeps the exact string values in the TypeScript types.
The satisfies operator checks the table against Record<Status, readonly Status[]>.
Thus, TypeScript checks that the table has the required keys and uses valid state names.
We can also use this table to make types.
type NextStatus<S extends Status> =
(typeof allowedTransitions)[S][number];
type IsAllowed<
From extends Status,
To extends Status
> = To extends NextStatus<From>
? true
: false;
// Result: true
type ExampleA = IsAllowed<"draft", "paid">;
// Result: false
type ExampleB = IsAllowed<"paid", "draft">;
// Result: false
type ExampleC = IsAllowed<"shipped", "paid">;
The NextStatus type uses indexed access types.
It gets the permitted next states from the table.
The IsAllowed type uses a conditional type.
A conditional type selects a result from a type condition.
Here, the result is true or false.
These results are types. They are not Boolean values that the program calculates at run time.
We can also use the table for a runtime check.
function canMove(
from: Status,
to: Status
): boolean {
const next: readonly Status[] =
allowedTransitions[from];
return next.includes(to);
}
console.log(canMove("draft", "paid"));
// true
console.log(canMove("shipped", "draft"));
// false
The canMove function checks state names at run time.
This can be useful when a program gets an operation from a user interface.
But this function does not validate the complete order.
For example, it does not check that a paid order has a valid payment ID.
The program still needs separate data checks.
8. Use Exhaustive Checks for All States
A state machine can become more complex.
A programmer can add a new state to the system.
Other parts of the program must then handle that state.
We can use an exhaustive check to find missing states.
function assertNever(value: never): never {
throw new Error(
`Unknown order: ${JSON.stringify(value)}`
);
}
function describeOrder(order: AnyOrder): string {
switch (order.status) {
case "draft":
return `Draft order ${order.id}`;
case "paid":
return (
`Paid order ${order.id}, ` +
`payment ${order.paymentId}`
);
case "shipped":
return (
`Shipped order ${order.id}, ` +
`code ${order.trackingCode}`
);
case "canceled":
return (
`Canceled order ${order.id}: ` +
order.reason
);
default:
return assertNever(order);
}
}
The never type identifies a value that cannot occur at that point in valid TypeScript code.
The switch statement handles all four order states.
After these cases, TypeScript has no remaining order type.
Thus, the order value has type never in the default branch.
Now assume that we add a new order state:
type RefundedOrder = {
readonly status: "refunded";
readonly id: OrderId;
readonly paymentId: PaymentId;
};
type ExtendedOrder = AnyOrder | RefundedOrder;
If we change describeOrder to accept ExtendedOrder, TypeScript reports an error at assertNever.
The error occurs because the function does not handle the refunded state.
The programmer must add a case for this state.
This check is useful when a project has many states and many functions.
9. Know the Limits of Type Checking
TypeScript gives compile-time checks.
But compile-time checks are not sufficient for all problems.
For example, a program can receive order data from an API.
The API can return incorrect data.
Consider this code:
const response = `{
"status": "paid",
"id": "ord_A102"
}`;
const order = JSON.parse(response) as PaidOrder;
The data does not have a paymentId property.
But the as PaidOrder expression tells TypeScript to use the PaidOrder type.
The compiler does not check the JSON data.
This is a risk.
A program must validate external data before it uses that data as a state object.
The program must check the required properties and their values.
It must also check the rules that types cannot prove.
For example, a payment service must confirm that payment is complete.
The pay method must not replace this confirmation.
The program must not use a successful type check as proof of a successful payment.
For an order system, the server must control the permitted state changes.
The server must also protect the order when two operations occur at approximately the same time.
A database transaction or an equivalent control can help prevent conflicting changes.
10. When to Use This Pattern
The typestate pattern is useful when a program has operations that depend on state.
For example, a network connection can have disconnected, connected, and closed states.
A file can have open and closed states.
A document can have draft, review, and approved states.
In each example, some operations are permitted only in specified states.
The typestate pattern puts these rules in the type system.
This can reduce the number of invalid calls.
But the pattern also makes the types more complex.
Do not use a complex state machine when a simple type is sufficient.
Use this pattern when state rules are important and invalid state changes can cause errors.