TypeScript Patterns That Separate Junior Code From Senior Code
The type-level techniques that make codebases maintainable at scale
Most TypeScript codebases I audit use types the same way JavaScript developers use comments — as documentation that might be wrong. They sprinkle string and number on function signatures and call it type safety. But real TypeScript power comes from making invalid states unrepresentable — designing your types so that bugs literally can't compile.
Here are the patterns that separate "TypeScript as documentation" from "TypeScript as a safety net."
Pattern 1: Discriminated Unions Over Optional Fields
This is the single most impactful TypeScript pattern. It eliminates an entire category of impossible-state bugs.
// BAD: Optional fields lead to impossible states
interface ApiResponse {
status: "loading" | "success" | "error";
data?: User[];
error?: string;
}
// What does { status: "success", error: "something" } mean?
// What does { status: "loading", data: [...] } mean?
// TypeScript allows both. Both are bugs.
// GOOD: Discriminated union makes impossible states unrepresentable
type ApiResponse =
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: string };
// Now TypeScript enforces:
// - "loading" has no data and no error
// - "success" always has data, never has error
// - "error" always has error, never has data
Using it in components:
function UserList({ response }: { response: ApiResponse }) {
switch (response.status) {
case "loading":
return <Skeleton />;
case "success":
// TypeScript knows response.data exists here
return <List items={response.data} />;
case "error":
// TypeScript knows response.error exists here
return <ErrorMessage message={response.error} />;
}
}
No optional chaining. No null checks. No "this shouldn't happen" comments. The type system guarantees correctness.
Pattern 2: Branded Types for Domain Identifiers
Ever accidentally passed a userId where a projectId was expected? With plain strings, TypeScript can't catch this.
// BAD: All IDs are just strings
function getProject(projectId: string): Project { ... }
function getUser(userId: string): User { ... }
// This compiles but is a bug:
const user = getUser(project.id); // Oops, passed project ID to user function
// GOOD: Branded types prevent ID mixups
type UserId = string & { readonly __brand: "UserId" };
type ProjectId = string & { readonly __brand: "ProjectId" };
function userId(id: string): UserId { return id as UserId; }
function projectId(id: string): ProjectId { return id as ProjectId; }
function getProject(id: ProjectId): Project { ... }
function getUser(id: UserId): User { ... }
// Now this is a compile error:
const user = getUser(project.id); // Type 'ProjectId' is not assignable to 'UserId'
This is especially powerful in multi-tenant systems where mixing up organizationId, projectId, userId, and memberId is a real security risk.
Pattern 3: Type-Safe API Layers
Don't trust that your API returns what you expect. Validate at the boundary and type the rest.
import { z } from "zod";
// Define the schema
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
role: z.enum(["admin", "member", "viewer"]),
createdAt: z.string().datetime(),
});
type User = z.infer<typeof UserSchema>;
// API function validates at the boundary
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return UserSchema.parse(data); // Throws if API response doesn't match
}
Now every consumer of getUser gets a guaranteed User shape — not a hopeful one. If the backend changes its response format, you get a runtime error at the boundary instead of a subtle bug three components deep.
Pattern 4: Exhaustive Pattern Matching
Ensure you handle every case of a union type — and get a compile error when new cases are added.
type OrderStatus = "pending" | "processing" | "shipped" | "delivered" | "cancelled";
function getStatusColor(status: OrderStatus): string {
switch (status) {
case "pending": return "yellow";
case "processing": return "blue";
case "shipped": return "purple";
case "delivered": return "green";
case "cancelled": return "red";
default: {
// This line ensures compile error if a new status is added
// but not handled in this switch
const _exhaustive: never = status;
return _exhaustive;
}
}
}
// Later, someone adds "refunded" to OrderStatus:
// This function immediately gets a compile error: "Type 'refunded' is not assignable to type 'never'"
// The developer is forced to handle it.
Pattern 5: Const Assertions for Configuration
// BAD: TypeScript infers { name: string, path: string }[]
const routes = [
{ name: "Dashboard", path: "/dashboard" },
{ name: "Settings", path: "/settings" },
];
// GOOD: TypeScript infers the literal types
const routes = [
{ name: "Dashboard", path: "/dashboard" },
{ name: "Settings", path: "/settings" },
] as const;
// Now you can derive types from the data
type RouteName = (typeof routes)[number]["name"]; // "Dashboard" | "Settings"
type RoutePath = (typeof routes)[number]["path"]; // "/dashboard" | "/settings"
// And use them for type-safe navigation
function navigate(path: RoutePath) { ... }
navigate("/dashboard"); // OK
navigate("/nonexistent"); // Compile error
Pattern 6: Generic Components with Constraints
// A table component that's type-safe for any data shape
interface Column<T> {
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => React.ReactNode;
}
interface DataTableProps<T extends { id: string }> {
data: T[];
columns: Column<T>[];
onRowClick?: (row: T) => void;
}
function DataTable<T extends { id: string }>({
data,
columns,
onRowClick,
}: DataTableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.id} onClick={() => onRowClick?.(row)}>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render
? col.render(row[col.key], row)
: String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// Usage: full type safety, autocomplete on column keys
<DataTable
data={users}
columns={[
{ key: "name", header: "Name" },
{ key: "email", header: "Email" },
{ key: "role", header: "Role", render: (value) => <Badge>{value}</Badge> },
{ key: "nonexistent", header: "Oops" }, // Compile error!
]}
/>
The Mindset Shift
Junior TypeScript: "How do I type this so the red squiggles go away?"
Senior TypeScript: "How do I type this so bugs can't exist?"
The difference isn't about knowing more types. It's about designing types that encode your business rules so deeply that violating them requires fighting the compiler. When your types are right, entire categories of bugs become impossible — not unlikely, not caught by tests, but literally impossible to write.
That's not just type safety. That's architecture.