Making illegal states unrepresentable in TypeScript
There’s always been a desire to have very strict guardrails on code (conventions, type safety, strong linting, perfect code coverage, etc) in order to prevent certain classes of bugs. However for many teams, these desires have come in tension with the practicalities of running a software team. For instance, every new guardrail could historically make it slower to onboard a new engineer, increase developer frustration, and slow a team’s velocity.
Before AI coding agents, we optimized for the ability to write and maintain code in a fast and high quality way. Now that code is cheap, we need to shift focus to how fast humans can validate code without sacrificing quality.
One of the best ways to increase confidence in your code is by using a strict type system. While the following patterns add additional verbosity which makes code slightly slower to read, they are such powerful guardrails that I believe they are almost always worth adopting in AI-generated code.
The goal behind all of them is is to increase confidence in your code by illegal states unrepresentable, so that you can review code faster and more confidently.
Here’s a tour of some of the advanced patterns you can use in TypeScript to make your code safer. Each section header links out to a deeper, third-party write-up if you want more detail on a pattern.
Branded types
// Bad
declare function getUser(id: string): User;
const orderId = "ord_123";
// Compiles fine, even though orderId is an order id, not a user id
getUser(orderId);
// Good
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
declare function getUser(id: UserId): User;
const orderId = "ord_123" as OrderId;
// Error: OrderId is not assignable to UserId
getUser(orderId);
__brand is a fake property that exists only in the type. The as keyword, in this example, is used to explicitly give something a branded type.
Since types are stripped out by the TypeScript compiler, at runtime UserId and OrderId will just be normal strings.
A best practice is to check a value once at a boundary of your system, give it a branded type, and trust the type from then on.
In practice, it’s usually best to create a utility type that helps create branded types like so:
// Brand is a utility type for creating branded types
type Brand<T, U extends string> = T & { readonly __brand: U };
// UserId and OrderId are branded types created using Brand
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
declare function getUser(id: UserId): User;
const orderId = "ord_123" as OrderId;
// Error: OrderId is not assignable to UserId
getUser(orderId);
Smart constructors
// Bad
declare function average(values: number[]): number;
// Dangerous! This compiles, even though the average of an empty array is undefined
average([]);
// Good
// This `readonly` keyword prevents the array from mutating with a `.pop()` for instance after you've already checked it!
type NonEmptyArray<T> = readonly [T, ...T[]];
// nonEmptyArray is the smart constructor
function nonEmptyArray<T>(
first: T,
...rest: T[]
): NonEmptyArray<T> {
return [first, ...rest];
}
declare function average(
values: NonEmptyArray<number>,
): number;
// Error: an empty array is not assignable to NonEmptyArray<number>
average([]);
// Valid: the smart constructor requires at least one value
average(nonEmptyArray(10, 20, 30));
NonEmptyArray<T> is a type that guarantees an array contains at least one item. What’s new is the nonEmptyArray function. Its parameters are designed so that every possible call produces a valid NonEmptyArray: the caller has to provide the first item.
A function that provides a controlled way to create a type while preserving that type’s invariants is often called a smart constructor. Despite the name, it does not need to be a JavaScript class constructor. It can be a regular function.
You would typically handle that failure once at the boundary of your system. From then on, your code can use NonEmptyArray<T> with confidence that the array contains at least one item.
Partial smart constructors
If a smart constructor can return undefined, it is called a “partial smart constructor.” For instance, toDateRange below is a partial smart constructor.
// DateRange is a branded type, so toDateRange is the only safe way to make one
type DateRange = Brand<{ readonly start: Date; readonly end: Date }, "DateRange">;
function toDateRange(
start: Date,
end: Date,
): DateRange | undefined {
if (start.getTime() > end.getTime()) {
return undefined;
}
return {
start,
end,
} as DateRange;
}
Wrapper types for taint tracking
Taint tracking means marking a value as sensitive (an API key, a password, a token) so the type system stops it from flowing somewhere dangerous, like a log, until you explicitly unwrap it.
// Bad
const apiKey: string = user.apiKey;
// Dangerous! This compiles, and a secret will leak in the logs
log(apiKey);
// Good
// Sensitive<T> is a wrapper: the value lives inside it, not alongside
type Sensitive<T> = { readonly __sensitive: T };
function protect<T>(value: T): Sensitive<T> {
return { __sensitive: value };
}
function reveal<T>(value: Sensitive<T>): T {
return value.__sensitive;
}
// apiKey is now protected
const apiKey: Sensitive<string> = protect(user.apiKey);
// Error: Sensitive<string> is not assignable to string
log(apiKey);
// Succeeds. Every "reveal" call is greppable, so it is easy to audit.
log(reveal(apiKey));
Because Sensitive<string> is a wrapper and not a string, passing it straight to log fails to compile. That is the point.
The only way you should get the raw value out is by using the reveal function, and every reveal function call is easy to grep for when auditing your code. In practice, you should combine this technique with some static analysis tools like a custom linter that enforces that reveal is only used at allowed times, and that reveal is the only function that accesses the __sensitive key.
Discriminated unions
// Bad
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
// If we later add { kind: "triangle" } this will return 0
// But it would be better if it returned a compilation error
default: return 0;
}
}
// Good
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
// Added later
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
default:
// Error: 'triangle' is not assignable to 'never'
const __exhaustive: never = shape;
return __exhaustive;
}
}
In the example above, Shape is the part that’s specifically called a “discriminated union.” The word “discriminated” refers to the fact that they all share a property that distinguishes them, which in this example is kind.
If you add a new case and forget to handle it in this switch statement, the assignment will fail to compile, because the new case is not assignable to never.
Discriminated unions are super powerful and versatile. For instance, in the code below, TypeScript is smart enough to infer that circles is of type Circle[].
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
// Uses the `Extract` utility type built into TypeScript
type Circle = Extract<Shape, { kind: "circle" }>;
function isCircle(shape: Shape): shape is Circle {
return shape.kind === "circle";
}
const shapes: Shape[] = [
{ kind: "circle", radius: 10 },
{ kind: "square", side: 5 },
];
// "circles" has inferred type Circle[]
const circles = shapes.filter(isCircle);
That tells TypeScript that whenever isCircle(shape) returns true, the value has been narrowed from Shape to Circle.
Because filter preserves the type narrowing from the isCircle predicate, the resulting array is inferred as Circle[] instead of Shape[]. You can therefore access radius on every item without another type check.
Typestate
// Bad
conn.close();
// Compiles, but throws at runtime: the connection is closed
conn.send("data");
// Good
declare class Connection<S extends "open" | "closed"> {
// Pin the state into the type so the two instantiations actually differ
private readonly _state: S;
send(this: Connection<"open">, data: string): void;
close(this: Connection<"open">): Connection<"closed">;
}
declare const conn: Connection<"open">;
const closed = conn.close();
// Error: The 'this' context of type 'Connection<"closed">'
// is not assignable to method's 'this' of type 'Connection<"open">'
closed.send("data");
Typestate puts the object’s state into its type, so the methods you can call change with the state.
send(this: Connection<"open">, ...) says you can only call send when this is an open connection.
close() returns a new Connection<"closed">. Calling send requires this to be Connection<"open">, so calling it on a closed connection is a TypeScript compile error.
Template literal types
// Bad
declare function navigate(route: string): void;
// Dangerous! This will compile even though there's a typo
navigate("/usrs/42");
// Good
type Entity = 'users' | 'posts'
type Route = `/${Entity}/${number}`
declare function navigate(route: Route): void;
// Error: '/usrs/42' is not assignable to Route
navigate("/usrs/42");
Just like in JavaScript, you can use template literals in TypeScript’s type system.
A slot like ${number} matches any string with a number in that position just like ${Entity} matches any entity in that position. So /users/42 is a valid Route, but /usrs/42 is not.
Effect: errors and requirements
By default, TypeScript has no way to enforce that exceptions are properly handled!
Effect comes to the rescue as a very opinionated way to make both errors and requirements (the dependencies a function needs) an explicit part of its type.
Let’s say you have the following Effect code
import { Effect, Context, Data } from "effect"
type Receipt = { id: string }
// `Data.TaggedError` makes an error class with a
// string tag (`CardDeclinedError`) that you can later catch by name
class CardDeclinedError extends Data.TaggedError("CardDeclinedError")<{
reason: string
}> {}
// `Context.Tag` defines a requirement.
// Effect has a dependency injection mechanism that provides these
// to the functions that need them. This `PaymentGateway` class is
// the key Effect uses to track and inject that requirement.
class PaymentGateway extends Context.Tag("PaymentGateway")<
PaymentGateway,
{ chargeCard: (id: string) => Effect.Effect<Receipt, CardDeclinedError> }
>() {}
/**
* Effect.Effect is parameterized by up to three types.
* The first indicates what it returns when it succeeds, e.g. `Receipt`
* The second indicates what it returns when it fails, e.g. `CardDeclinedError`
* The third indicates its requirements, the dependencies it needs, e.g. `PaymentGateway`
*/
declare function chargeCard(
id: string
): Effect.Effect<Receipt, CardDeclinedError, PaymentGateway>
Effect code is strange the first time you see it, but just think of it as a library that makes sure you’re 100% explicit about all of the errors and requirements of each of your functions.
// Bad
// Error: Type 'PaymentGateway' is not assignable to type 'never'
// `runPromise` only runs an Effect with no leftover requirements.
// chargeCard still needs PaymentGateway, which is why this fails to compile.
const result = await Effect.runPromise(chargeCard(id))
The type signature says chargeCard can fail with CardDeclinedError and has a requirement PaymentGateway. The compiler will not let you run it until that requirement is provided.
You don’t strictly have to handle CardDeclinedError for the code to compile. If the Effect fails, the promise returned by Effect.runPromise(...) will reject at runtime. However, you will often handle expected errors inside Effect before running it. Handling CardDeclinedError removes it from the Effect’s error type. If no other errors remain, the error type becomes never.
// Good
// Handle the error and provide the service `paymentGateway`
// that fulfills the requirement `PaymentGateway`
const paymentGateway = {
// Pretend we actually charge a card here
chargeCard: (id: string) =>
Math.random() > 0.5
? Effect.succeed({ id: `receipt_${id}` })
: Effect.fail(new CardDeclinedError({ reason: "insufficient funds" })),
}
// Effect is declarative instead of imperative. That's what makes
// it so powerful, but it also makes the syntax a little cumbersome
const settled = chargeCard(id).pipe(
// Handle the expected error
Effect.catchTag("CardDeclinedError", (e) =>
Effect.succeed(`Declined: ${e.reason}`)
),
// Provide the needed `paymentGateway` service that fulfills the `PaymentGateway` requirement
Effect.provideService(PaymentGateway, paymentGateway)
)
// This is the part that actually executes the declarative Effect code
const result = await Effect.runPromise(settled)
After catchTag and provideService, settled is Effect<Receipt | string, never, never>: the success type gained string because the caught branch returns one, and both the error and the requirements are now never, so it is runnable.
Beyond TypeScript
function at<T>(arr: T[], i: number): T {
// There is no way to ensure that `i` is not out of bounds
return arr[i];
}
There are some things that no TypeScript type can promise.
For instance, the function above wants to guarantee that i is a valid index. But whether it is depends on a value that only exists once the program runs, and TypeScript’s types are gone by then.
TypeScript’s noUncheckedIndexedAccess feature helps a little. It types arr[i] as T | undefined, forcing you to handle the missing case. But it still cannot prove the index is valid inside the type system.
Similarly, no TypeScript type can represent “this list is sorted” or “this file handle is used exactly once.” Guarantees like these need a more powerful type system or proof tool, such as Rust, Idris, or Dafny, or a model checker like TLA+.
Despite these limitations, the patterns above give you a lot of ways to make your types safer.