cd ../writing
// typescript · advanced patterns

TypeScript power patterns — turning types into a design tool.

Most developers stop at interface User { id: string }. TypeScript's type system can do much more — it can enforce correctness at compile time that would otherwise require runtime checks. These are the patterns that elevate TypeScript from a checker to a design language: generics, conditional types, the satisfies operator, branded types, template literals.

7 patterns real use cases TS 5.x features © use freely

01The satisfies operator — get both worlds

Often you want to declare an object matches a type without widening the inferred type. The classic problem:

✗ type annotation widens inference
type Config = Record<string, string | number>;

const config: Config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
};

// config.apiUrl is typed as string | number — too wide!
config.apiUrl.toUpperCase(); // ✗ Property 'toUpperCase' does not exist on number
✓ satisfies — validates AND preserves narrow type
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
} satisfies Config;

config.apiUrl.toUpperCase(); // ✓ apiUrl is correctly typed as string
config.timeout.toFixed(2);  // ✓ timeout is correctly typed as number

satisfies checks that the value matches the type but preserves the specific inferred type of each property. Available since TypeScript 4.9 (November 2022). Use it everywhere you'd previously use as Type or a type annotation that widens.

02Branded types — distinguishing identical primitives

string is too coarse. A user ID, an email, and an API key are all strings, but they shouldn't be interchangeable. Branded types fix this without runtime overhead.

✓ branded types
type Brand<T, B> = T & { __brand: B };

type UserId = Brand<string, 'UserId'>;
type Email  = Brand<string, 'Email'>;

function parseUserId(s: string): UserId {
  if (!s.startsWith('user_')) throw new Error('Invalid user ID');
  return s as UserId;
}

function sendEmail(to: Email, message: string) { /* ... */ }

const id = parseUserId('user_abc');
sendEmail(id, 'hello');
// ✗ Argument of type 'UserId' is not assignable to parameter of type 'Email'

At runtime, branded types are just strings — zero overhead. At compile time, you can't mix them up. The compile-time check eliminates an entire category of bugs (passing a user ID where an email was expected) that TypeScript otherwise can't catch.

03Discriminated unions — typesafe state machines

For any state that can be in one of several distinct shapes, discriminated unions are the right tool.

✓ discriminated union for fetch state
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function render<T>(state: FetchState<T>) {
  switch (state.status) {
    case 'success':
      return state.data; // ✓ TypeScript knows .data exists here
    case 'error':
      return state.error.message;
    case 'loading':
    case 'idle':
      return null;
  }
}

The compiler narrows the type inside each case automatically. state.data only exists in the success branch; trying to access it elsewhere is a compile error. This eliminates "cannot read property X of undefined" entirely for this shape of code.

04Conditional types — types that depend on types

Sometimes you need a type to vary based on another type's structure. Conditional types use a ternary syntax at the type level.

✓ extract array element type
type ElementOf<T> = T extends (infer U)[] ? U : never;

type A = ElementOf<string[]>;        // string
type B = ElementOf<Array<number>>;   // number
type C = ElementOf<boolean>;         // never

The infer keyword captures a type from a pattern match. Read the above as: "if T is an array of some type U, then U; otherwise never." This is how built-in types like ReturnType, Parameters, and Awaited are defined under the hood.

05Template literal types — string types with structure

Template literal types let you describe specific string patterns at the type level.

✓ template literal types
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Route<M extends HttpMethod> = `${M} /api/${string}`;

const r1: Route<'GET'> = 'GET /api/users';        // ✓
const r2: Route<'GET'> = 'POST /api/users';       // ✗ doesn't start with 'GET'

// Combined with conditional types for parsing:
type ParseRoute<S> = S extends `${infer M} ${infer P}`
  ? { method: M; path: P }
  : never;

type R = ParseRoute<'GET /api/users'>;
// { method: 'GET'; path: '/api/users' }

Template literal types unlock typesafe routing, typesafe SQL, typesafe Tailwind class strings — entire libraries (like Zod, tRPC) lean heavily on this feature.

06Mapped types — transforming object shapes

Mapped types iterate over the keys of a type and transform each one.

✓ mapped types in action
// Built-in: Partial — make all properties optional
type Partial<T> = { [K in keyof T]?: T[K] };

// Built-in: Readonly
type Readonly<T> = { readonly [K in keyof T]: T[K] };

// Custom: getter pattern
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type User = { name: string; age: number };
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }

Mapped types let you generate variants of existing types automatically. Partial, Required, Readonly, Pick, Omit, Record are all built on this primitive.

07const assertions — preserve literal types

TypeScript widens literal types by default. const x = 'hello' infers string, not 'hello'. as const preserves the literal:

✓ const assertions
const roles = ['admin', 'user', 'guest'] as const;
// type: readonly ['admin', 'user', 'guest']

type Role = typeof roles[number];
// type: 'admin' | 'user' | 'guest'

// Without 'as const':
// roles would be string[], Role would be string

Pattern: declare your constants with as const, derive types from them with typeof. The constant and the type stay in sync automatically — change the array, the type updates.

The shift

TypeScript at its best isn't about catching bugs after you've made them. It's about making invalid states unrepresentable — designing your types so that bugs can't be expressed. A discriminated union for state can't be in an impossible combination. A branded type can't be confused with an unrelated primitive. A template literal type can't accept a malformed URL.

The patterns above are the foundation. Once they become natural, you'll start designing types first and writing implementation second — and the implementation often falls out almost for free once the types are right.