TypeScript Patterns That Eliminate Runtime Errors
TypeScript catches type errors at compile time. But most production bugs are not simple type mismatches — they are null values, impossible states, and unhandled edge cases that TypeScript can catch if you use the right patterns.
Discriminated Unions for State
Instead of a type with optional fields:
// BAD: Fields can be in impossible combinations
interface ApiResponse {
data?: User[];
error?: string;
loading: boolean;
}
Use a discriminated union:
// GOOD: Each state is explicit and complete
type ApiState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: string };
With the discriminated union, you cannot have data and error at the same time. TypeScript enforces exhaustive handling:
function renderUsers(state: ApiState) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return state.data.map(u => <UserCard key={u.id} user={u} />);
case "error":
return <ErrorMessage message={state.error} />;
}
}
If you add a new status later, TypeScript will flag every switch statement that does not handle it.
Branded Types
Primitive types like string and number look identical to TypeScript even when they represent different concepts:
// BAD: Easy to pass userId where postId is expected
function getPost(postId: string, userId: string): Post { ... }
getPost(userId, postId); // No error — both are strings
Branded types prevent this:
type UserId = string & { readonly __brand: "UserId" };
type PostId = string & { readonly __brand: "PostId" };
function userId(id: string): UserId { return id as UserId; }
function postId(id: string): PostId { return id as PostId; }
function getPost(postId: PostId, userId: UserId): Post { ... }
const uid = userId("user-123");
const pid = postId("post-456");
getPost(uid, pid); // ERROR: UserId is not assignable to PostId
getPost(pid, uid); // Correct
This costs nothing at runtime — the brands exist only in the type system.
The satisfies Operator
satisfies validates that a value matches a type without widening it:
type Route = "/home" | "/blog" | "/contact";
// With `as const` + `satisfies`, TypeScript knows the exact values
const routes = {
home: "/home",
blog: "/blog",
contact: "/contact",
} as const satisfies Record<string, Route>;
// TypeScript knows routes.home is exactly "/home", not just Route
type HomeRoute = typeof routes.home; // "/home"
Without satisfies, you need to choose between type safety (annotation that widens the type) and type inference (no annotation that loses validation).
Exhaustive Checks with never
Ensure switch statements handle all cases:
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${value}`);
}
type Status = "active" | "inactive" | "pending";
function getLabel(status: Status): string {
switch (status) {
case "active": return "Active";
case "inactive": return "Inactive";
case "pending": return "Pending";
default: return assertNever(status);
}
}
If someone adds "archived" to the Status type, assertNever(status) will show a compile error because "archived" is not assignable to never.
Const Assertions for Immutable Data
// Without as const: type is { role: string }
const config = { role: "admin" };
// With as const: type is { readonly role: "admin" }
const config = { role: "admin" } as const;
as const makes the entire object deeply readonly with literal types. This is useful for configuration objects, enum-like constants, and data that should never change.
Template Literal Types
type EventName = "click" | "hover" | "focus";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onHover" | "onFocus"
type ApiEndpoint = `/api/${string}`;
function fetchData(url: ApiEndpoint) { ... }
fetchData("/api/users"); // OK
fetchData("/users"); // ERROR
Template literals enforce string patterns at the type level.
Strict Null Checks in Practice
With strictNullChecks enabled (which it should always be):
function getUser(id: string): User | null {
return db.find(u => u.id === id) ?? null;
}
// TypeScript forces you to handle null
const user = getUser("123");
console.log(user.name); // ERROR: Object is possibly null
// Handle it explicitly
if (user) {
console.log(user.name); // OK
}
Takeaways
TypeScript is most powerful when you use the type system to make invalid states unrepresentable. Discriminated unions eliminate impossible state combinations. Branded types prevent argument mix-ups. Exhaustive checks catch missing cases at compile time. These patterns add no runtime cost — they exist entirely in the type system and vanish after compilation.