TypeScript Patterns I Use Every Day
typescriptdx

TypeScript Patterns I Use Every Day

Practical TypeScript patterns that have genuinely improved the quality and maintainability of my codebases — beyond the basics you already know.

Arian ShahArian Shah
··3 min read

Most TypeScript tutorials cover interface vs type and basic generics. This isn't that article. These are the patterns I reach for on real projects.

Discriminated unions over optional fields

Optional fields are a code smell disguised as convenience. When you see this:

type Response = {
  data?: User;
  error?: string;
  loading?: boolean;
};

You get into a situation where data and error can both be defined at the same time, which is nonsensical. A discriminated union is explicit about what states are actually possible:

type Response =
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string };

Now TypeScript narrows correctly in switch statements, and impossible states are literally unrepresentable.

satisfies for config objects

The satisfies operator (introduced in TS 4.9) is underused. It validates that a value matches a type, but preserves the literal type rather than widening it.

const config = {
  theme: "dark",
  lang: "en",
} satisfies Record<string, string>;
 
// config.theme is "dark" (literal), not string
// without satisfies, you'd need `as const` and lose the type check

I use this constantly for route configs, theme objects, and feature flag maps.

Template literal types for string safety

String-based APIs are bug magnets. Template literal types give you full autocomplete and type checking on string values:

type Direction = "top" | "right" | "bottom" | "left";
type Padding = `p${Capitalize<Direction>}`;
// "pTop" | "pRight" | "pBottom" | "pLeft"
 
type SpacingKey = `spacing-${1 | 2 | 4 | 8 | 16}`;
// "spacing-1" | "spacing-2" | "spacing-4" | "spacing-8" | "spacing-16"

This is especially powerful for design token systems and CSS-in-JS utilities.

Branded types for semantic clarity

TypeScript's structural typing means UserId and PostId — both strings — are interchangeable. That's a bug waiting to happen.

type Brand<T, B> = T & { readonly _brand: B };
 
type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;
 
function getUser(id: UserId) { /* ... */ }
 
const postId = "post_123" as PostId;
getUser(postId); // ❌ Type error — exactly what we want

The runtime representation is still just a string. Zero cost, maximum safety.

infer in conditional types

Once it clicks, infer unlocks a whole class of utility types:

// Extract the resolved type of a Promise
type Awaited<T> = T extends Promise<infer R> ? R : T;
 
// Extract function return type (without using ReturnType<>)
type Return<T> = T extends (...args: any[]) => infer R ? R : never;
 
// Extract array element type
type Item<T> = T extends (infer E)[] ? E : never;

These compose. Need the return type of an async function?

type AsyncReturn<T> = Awaited<Return<T>>;

A note on any

any isn't always wrong. A // @ts-ignore or as any in a third-party integration, a genuinely dynamic runtime value, or a migration boundary is fine. What's not fine is using any because you haven't thought through the types yet. The discipline is in being intentional — use unknown as the default unknown type, and cast only when you've validated the shape.


TypeScript's type system is a tool for encoding invariants. The patterns above aren't clever tricks — they're ways to make the type system reflect reality more precisely, so the compiler catches real bugs instead of just inferring any.