TypeScript Patterns We Use in Every Project
We write TypeScript every day. Every backend service, every API route, every frontend component across all of our projects uses it. After five years and twelve-plus shipped products, we have settled on a set of patterns that show up in every codebase. These are not clever tricks or academic type gymnastics — they are practical patterns that prevent real bugs and make code easier to change.
This post covers the patterns we reach for most often: discriminated unions for state management, branded types for type-safe identifiers, exhaustive matching, Zod for runtime validation, and type-safe API clients.
Discriminated Unions for State
This is the single most useful TypeScript pattern we know. Instead of modeling state with a bag of optional properties, use a discriminated union where each variant carries exactly the data it needs.
The bad version:
type RequestState = {
loading: boolean;
data?: User[];
error?: string;
};
// This allows impossible states:
// { loading: true, data: [...], error: "something" }
The good version:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: string };
Now you cannot have data and an error at the same time. You cannot have data while still loading. The type system enforces valid state transitions.
We use this pattern everywhere — async data fetching, form states, payment flows, onboarding wizards. In MindHyv, where users move through a multi-step booking flow, the booking state is a discriminated union with variants for each step. The compiler tells us if we forget to handle a step.
type BookingFlow =
| { step: "select-service"; services: Service[] }
| { step: "select-time"; service: Service; slots: TimeSlot[] }
| { step: "confirm"; service: Service; slot: TimeSlot; client: Client }
| { step: "complete"; booking: Booking };
function renderBookingStep(flow: BookingFlow) {
switch (flow.step) {
case "select-service":
return <ServicePicker services={flow.services} />;
case "select-time":
return <TimePicker service={flow.service} slots={flow.slots} />;
case "confirm":
return <Confirmation service={flow.service} slot={flow.slot} client={flow.client} />;
case "complete":
return <BookingComplete booking={flow.booking} />;
}
}
Each branch has access to exactly the data it needs. No null checks, no type assertions, no runtime errors from accessing properties that do not exist.

Exhaustive Matching with never
The discriminated union pattern becomes even more powerful when you enforce exhaustive matching. If you add a new variant to the union, the compiler should tell you everywhere that needs to handle it.
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}
function getStatusLabel(status: BookingFlow["step"]): string {
switch (status) {
case "select-service":
return "Choose a service";
case "select-time":
return "Pick a time";
case "confirm":
return "Review and confirm";
case "complete":
return "All done";
default:
return assertNever(status);
}
}
If you add a new step to BookingFlow — say "payment" — the assertNever call will produce a compile error because "payment" is not assignable to never. This is the compiler telling you that you missed a case. It is cheaper than a bug report.
We have a shared assertNever utility in every project. It lives in src/lib/utils.ts and gets imported dozens of times per codebase.
Branded Types for IDs
This one has prevented more bugs than any other pattern. In a typical codebase, user IDs, project IDs, invoice IDs, and payment IDs are all string. Nothing stops you from passing a user ID where a project ID is expected:
// These are all just strings at the type level
function getInvoice(invoiceId: string): Promise<Invoice>;
function getUser(userId: string): Promise<User>;
// Compiles fine. Will return garbage or crash at runtime.
const invoice = await getInvoice(userId);
Branded types solve this by creating nominal types that are structurally incompatible:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type InvoiceId = Brand<string, "InvoiceId">;
type ProjectId = Brand<string, "ProjectId">;
function getInvoice(invoiceId: InvoiceId): Promise<Invoice>;
function getUser(userId: UserId): Promise<User>;
// Type error: Argument of type 'UserId' is not assignable to 'InvoiceId'
const invoice = await getInvoice(userId);
To create branded values, we use constructor functions that validate the input:
function toUserId(id: string): UserId {
if (!id || typeof id !== "string") {
throw new Error(`Invalid user ID: ${id}`);
}
return id as UserId;
}
In LancerSpace, where a single API route might handle client IDs, project IDs, invoice IDs, and proposal IDs, branded types make it impossible to mix them up. The mental overhead of “which string is which” disappears entirely.
Zod for Runtime Validation
TypeScript types vanish at runtime. Data coming from API requests, form submissions, or external services has no type guarantees. Zod bridges this gap by letting you define a schema that validates at runtime and infers TypeScript types at compile time.
import { z } from "zod";
const CreateProjectSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
client_id: z.string().uuid(),
budget: z.number().positive().optional(),
deadline: z.coerce.date().optional(),
tags: z.array(z.string()).max(10).default([]),
});
type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
// { name: string; description?: string; client_id: string; budget?: number; ... }
We validate every API input with Zod. No exceptions. Here is the pattern we use in our API routes:
import { z, ZodError } from "zod";
function validateBody<T>(schema: z.ZodSchema<T>, body: unknown): T {
try {
return schema.parse(body);
} catch (err) {
if (err instanceof ZodError) {
const messages = err.errors.map((e) => `${e.path.join(".")}: ${e.message}`);
throw new ApiError(400, "Validation failed", messages);
}
throw err;
}
}
// In a route handler
async function handleCreateProject(req: Request) {
const input = validateBody(CreateProjectSchema, await req.json());
// input is fully typed as CreateProjectInput
const project = await createProject(input);
return Response.json(project);
}
The schema is the single source of truth. It defines the validation rules, the error messages, and the TypeScript type. When the requirements change, you update one thing.
We covered how Zod integrates with our API architecture in our post about webhook design, where schema validation is critical for incoming webhook payloads.

Type-Safe API Clients
When your frontend calls your backend, the type safety should not stop at the network boundary. We define shared types that both sides use, so the compiler catches mismatches before they become runtime errors.
For simple APIs, a typed fetch wrapper works well:
type ApiRoutes = {
"GET /api/projects": { response: Project[] };
"GET /api/projects/:id": { params: { id: string }; response: Project };
"POST /api/projects": { body: CreateProjectInput; response: Project };
"PATCH /api/projects/:id": { params: { id: string }; body: Partial<CreateProjectInput>; response: Project };
"DELETE /api/projects/:id": { params: { id: string }; response: void };
};
async function api<R extends keyof ApiRoutes>(
route: R,
options?: {
params?: "params" extends keyof ApiRoutes[R] ? ApiRoutes[R]["params"] : never;
body?: "body" extends keyof ApiRoutes[R] ? ApiRoutes[R]["body"] : never;
}
): Promise<ApiRoutes[R]["response"]> {
const [method, path] = (route as string).split(" ");
let url = path;
if (options?.params) {
for (const [key, value] of Object.entries(options.params as Record<string, string>)) {
url = url.replace(`:${key}`, encodeURIComponent(value));
}
}
const res = await fetch(url, {
method,
headers: options?.body ? { "Content-Type": "application/json" } : undefined,
body: options?.body ? JSON.stringify(options.body) : undefined,
});
if (!res.ok) throw new ApiError(res.status, await res.text());
if (res.status === 204) return undefined as ApiRoutes[R]["response"];
return res.json();
}
Usage is fully typed:
// TypeScript knows this returns Project[]
const projects = await api("GET /api/projects");
// TypeScript knows this needs params.id and returns Project
const project = await api("GET /api/projects/:id", { params: { id: "abc" } });
// TypeScript knows this needs a body of type CreateProjectInput
await api("POST /api/projects", { body: { name: "New project", client_id: "xyz" } });
For more complex APIs, we use tRPC or generate types from the API schema. But for most of our projects, this lightweight approach is sufficient and avoids the overhead of a framework.
Utility Types We Actually Use
TypeScript ships with many utility types. We use a handful of them constantly and a few custom ones.
Pick and Omit for creating subtypes:
type PublicUser = Pick<User, "id" | "name" | "avatar_url">;
type UserUpdate = Omit<User, "id" | "created_at" | "updated_at">;
Record for typed dictionaries:
type PermissionMap = Record<UserRole, Permission[]>;
Extract for narrowing union members:
type ErrorState = Extract<RequestState, { status: "error" }>;
// { status: "error"; error: string }
A custom utility we use in every project is StrictOmit, which errors if you try to omit a key that does not exist on the type:
type StrictOmit<T, K extends keyof T> = Omit<T, K>;
// This errors — "foo" is not a key of User
type Bad = StrictOmit<User, "foo">;
// Regular Omit silently does nothing
type AlsoStillUser = Omit<User, "foo">; // No error, which is dangerous
Another one is Prettify, which flattens intersection types for better readability in IDE tooltips:
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
// Before: Pick<User, "id" | "name"> & { role: UserRole }
// After Prettify: { id: string; name: string; role: UserRole }
type TeamMember = Prettify<Pick<User, "id" | "name"> & { role: UserRole }>;
Const Assertions and Derived Types
Instead of maintaining parallel enums and type definitions, derive types from a single source:
const PLAN_TIERS = ["free", "starter", "pro", "enterprise"] as const;
type PlanTier = (typeof PLAN_TIERS)[number];
// "free" | "starter" | "pro" | "enterprise"
const PLAN_LIMITS = {
free: { projects: 3, storage_mb: 100 },
starter: { projects: 10, storage_mb: 1000 },
pro: { projects: 50, storage_mb: 10000 },
enterprise: { projects: Infinity, storage_mb: Infinity },
} as const satisfies Record<PlanTier, { projects: number; storage_mb: number }>;
The satisfies keyword (TypeScript 4.9+) is extremely useful here. It validates that PLAN_LIMITS matches the expected shape while preserving the literal types of the values. You get both type safety and precise autocomplete.

Error Handling with Result Types
We do not throw errors in business logic. Instead, we return typed results:
type Result<T, E = string> =
| { ok: true; data: T }
| { ok: false; error: E };
function ok<T>(data: T): Result<T, never> {
return { ok: true, data };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
async function createBooking(input: CreateBookingInput): Promise<Result<Booking, "slot_taken" | "past_date" | "limit_reached">> {
const slot = await getTimeSlot(input.slot_id);
if (!slot) return err("slot_taken");
if (slot.start_time < new Date()) return err("past_date");
const count = await getBookingCount(input.client_id);
if (count >= PLAN_LIMITS[input.plan].projects) return err("limit_reached");
const booking = await insertBooking(input);
return ok(booking);
}
The caller is forced to handle both cases. No uncaught exceptions, no forgotten error paths. The error type is a union of specific string literals, so the compiler knows exactly which errors are possible.
Wrapping Up
None of these patterns are revolutionary. Discriminated unions, branded types, exhaustive matching, and Zod validation are well-documented TypeScript features. But the difference between knowing about them and using them consistently in every file, every module, every project is the difference between a codebase that fights you and one that helps you.
We have shipped these patterns across MindHyv, Trackelio, Vincelio, LancerSpace, and every other product we have built. They scale from side projects to production SaaS.
If you are building a TypeScript project and want a team that writes code this way from day one, reach out at [email protected].