Skip to content
Back to Software Dev
Software Dev4 min read

TypeScript Patterns I Use Daily

typescriptpatternssoftware-dev
Share

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 error

The __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.


Share

More in Software Dev