In traditional OOP and basic TypeScript, we often model domain entities using wide primitive types (like raw string or number) and optional fields ({ status?: string, error?: string }). This creates loose contracts where impossible application states are valid according to the compiler, forcing developers to write defensive runtime checks everywhere.
By combining two functional programming concepts, Branded Types and Algebraic Data Types (ADTs), we can push validation to the boundaries and ensure invalid states fail at compile time.
TypeScript uses a structural type system. Two types with the same shape are interchangeable, which allows bugs like swapping two UUID strings to go completely unnoticed by the compiler:
// Dangerous: Both are just strings
const assignProject = (userId: string, projectId: string) => { /* ... */ };
const userId = "usr_123";
const projectId = "prj_456";
// Compiles fine, but runtime logic breaks
assignProject(projectId, userId);A unique type brand makes structurally identical primitives incompatible. These are the IDs we will carry through the rest of the project management example:
declare const __brand: unique symbol;
type Brand<K, T> = K & { readonly [__brand]: T };
// Distinct domain types
export type UserId = Brand<string, "UserId">;
export type ProjectId = Brand<string, "ProjectId">;
export type Project = {
readonly id: ProjectId;
readonly ownerId: UserId;
readonly name: string;
};Instead of casting raw strings arbitrarily, create constructor functions that enforce business invariants once at the API boundary. The validated IDs can then be passed to the rest of the application:
export const parseUserId = (input: string): UserId => {
if (!/^usr_[a-z0-9]+$/.test(input)) {
throw new Error("Invalid user ID: " + input);
}
return input as UserId;
};
export const parseProjectId = (input: string): ProjectId => {
if (!/^prj_[a-z0-9]+$/.test(input)) {
throw new Error("Invalid project ID: " + input);
}
return input as ProjectId;
};
const userId = parseUserId("usr_123");
const projectId = parseProjectId("prj_456");
assignProject(userId, projectId);
// Compile Error: ProjectId is not assignable to UserId
assignProject(projectId, userId);A common way to model asynchronous UI state:
// Anti-Pattern: Allows impossible combinations
type AsyncState<T> = {
isLoading: boolean;
data?: T;
error?: string;
};
// An impossible project state that TypeScript permits:
const invalidState: AsyncState<Project> = {
isLoading: true,
data: { id: projectId, ownerId: userId, name: "Website redesign" },
error: "Network timeout"
};Use a tagged union where every state variant only contains the fields valid for that state:
export type RemoteData<E, D> =
| { readonly kind: "idle" }
| { readonly kind: "loading" }
| { readonly kind: "success"; readonly data: D }
| { readonly kind: "failure"; readonly error: E };
// Helper constructors
export const RemoteData = {
idle: (): RemoteData<never, never> => ({ kind: "idle" }),
loading: (): RemoteData<never, never> => ({ kind: "loading" }),
success: <D>(data: D): RemoteData<never, D> => ({ kind: "success", data }),
failure: <E>(error: E): RemoteData<E, never> => ({ kind: "failure", error }),
};
const projectState: RemoteData<string, Project> =
RemoteData.success({
id: projectId,
ownerId: userId,
name: "Website redesign",
});Consider an order with these business invariants:
transactionId and payment timestamp.transactionId and a trackingCode.export type OrderId = Brand<string, "OrderId">;
export type TrackingCode = Brand<string, "TrackingCode">;
export type TransactionId = Brand<string, "TransactionId">;
type BaseOrder = {
readonly id: OrderId;
readonly projectId: ProjectId;
readonly ownerId: UserId;
readonly items: ReadonlyArray<{ sku: string; quantity: number }>;
readonly totalAmountInCents: number;
};
export type Order =
| (BaseOrder & { readonly status: "draft" })
| (BaseOrder & {
readonly status: "paid";
readonly transactionId: TransactionId;
readonly paidAt: Date;
})
| (BaseOrder & {
readonly status: "shipped";
readonly transactionId: TransactionId;
readonly trackingCode: TrackingCode;
readonly shippedAt: Date;
})
| (BaseOrder & {
readonly status: "cancelled";
readonly reason: string;
readonly cancelledAt: Date;
});Transition functions take a specific state as an input and produce the next state. You cannot ship an unpaid draft because the compiler will not accept a draft order in a shipOrder function:
// Only accepts an order that is already paid
export const shipOrder = (
order: Extract<Order, { status: "paid" }>,
trackingCode: TrackingCode
): Extract<Order, { status: "shipped" }> => ({
...order,
status: "shipped",
trackingCode,
shippedAt: new Date(),
});Connecting this back to our previous post on ts-pattern:
import { match } from "ts-pattern";
export const renderOrderStatus = (order: Order): string =>
match(order)
.with({ status: "draft" }, () => "Order is being prepared.")
.with(
{ status: "paid" },
(paidOrder) => `Paid. Transaction: ${paidOrder.transactionId}`
)
.with(
{ status: "shipped" },
(shippedOrder) => `In transit. Tracking: ${shippedOrder.trackingCode}`
)
.with(
{ status: "cancelled" },
(cancelledOrder) => `Cancelled: ${cancelledOrder.reason}`
)
// If a new order status is added, exhaustive() will fail at build time
.exhaustive();isLoading and hasError that permit undefined states.