TypeScript Patterns I Use Daily
Most TypeScript I see in the wild is just JavaScript with : string sprinkled on top. That's not TypeScript. That's a type tax with no return.
Here are four patterns I use every day that make TypeScript earn its keep.
discriminated unions
This is the single most useful pattern in TypeScript. Instead of optional fields and runtime checks, you model your domain states explicitly:
type ApiState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderState(state: ApiState<User>) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return <UserCard user={state.data} />; // TS knows data exists
case "error":
return <ErrorBanner error={state.error} />; // TS knows error exists
}
}The discriminant field (status) tells the compiler exactly which fields exist in each branch. No optional chaining. No null checks. No as casts. The type narrows automatically.
I use this for everything: API responses, form states, auth flows, websocket connections. If your data can be in multiple states, it should be a discriminated union.
branded types
TypeScript's structural typing means a string is a string is a string. Which means you can accidentally pass a user ID where a project ID is expected and the compiler won't say a word.
Branded types fix this:
type UserId = string & { readonly __brand: "UserId" };
type ProjectId = string & { readonly __brand: "ProjectId" };
function createUserId(id: string): UserId {
return id as UserId;
}
function createProjectId(id: string): ProjectId {
return id as ProjectId;
}
function getUser(id: UserId) { /* ... */ }
function getProject(id: ProjectId) { /* ... */ }
const userId = createUserId("usr_123");
const projectId = createProjectId("proj_456");
getUser(userId); // works
getUser(projectId); // compile errorThe __brand field doesn't exist at runtime — it's purely a compiler hint. Zero cost. But it catches a category of bugs that unit tests miss entirely: wrong-ID-right-type.
I use this for any ID type that crosses function boundaries. Also useful for validated strings — EmailAddress, Url, NonEmptyString.
const assertions
as const tells TypeScript to infer the narrowest possible type. Instead of string[], you get a readonly tuple of literal types:
// Without as const — types widen
const ROLES = ["admin", "editor", "viewer"]; // string[]
// With as const — types stay narrow
const ROLES = ["admin", "editor", "viewer"] as const;
// readonly ["admin", "editor", "viewer"]
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"This is how I define enums. Not with the enum keyword — that generates runtime JavaScript and has weird behavior with reverse mappings. as const gives you type safety with zero runtime overhead.
It also works great for config objects:
const API_ROUTES = {
users: "/api/users",
projects: "/api/projects",
auth: "/api/auth",
} as const;
type ApiRoute = (typeof API_ROUTES)[keyof typeof API_ROUTES];
// "/api/users" | "/api/projects" | "/api/auth"Now your route strings are compile-time checked. Typo in a URL? The compiler catches it.
satisfies
The newest of the bunch. satisfies checks that a value matches a type without widening it:
type Theme = {
colors: Record<string, string>;
spacing: Record<string, number>;
};
// Using : Theme — widens the type, loses specifics
const theme: Theme = {
colors: { primary: "#2563EB", bg: "#FFFFFF" },
spacing: { sm: 4, md: 8, lg: 16 },
};
theme.colors.primary; // string (widened)
// Using satisfies — validates AND preserves literal types
const theme = {
colors: { primary: "#2563EB", bg: "#FFFFFF" },
spacing: { sm: 4, md: 8, lg: 16 },
} satisfies Theme;
theme.colors.primary; // "#2563EB" (preserved)The difference: with : Theme, TypeScript forgets the specific keys and values. With satisfies, it validates the shape but keeps the narrow types. You get both: compile-time validation that the config matches the expected structure, and autocomplete on the specific keys.
I use satisfies for route configs, theme objects, validation schemas — anything where I want "does this match the contract?" without losing type specificity.
the compound effect
None of these patterns is revolutionary in isolation. The value is using them together consistently. Discriminated unions for state, branded types for IDs, const assertions for enums, satisfies for configs. The result is a codebase where entire categories of bugs can't compile.
That's what TypeScript is for. Not : string annotations on everything. Actual type-level guarantees that catch real bugs at compile time.
More in Software Dev
Git Workflow for Solo Founders
Trunk-based dev, feature branches, conventional commits. What works when you're the only person pushing code.
My Code Review Checklist
What I look for when reviewing code: correctness, edge cases, naming, testing. Lessons from leading a team at Blinq.
Docker for Developers, Not Ops
Dev containers, multi-stage builds, compose for local dev. The Docker knowledge that actually matters when you're writing code, not managing infrastructure.