← Back to Blog

TypeScript Type System Tricks I Wish I Knew Earlier

· 4 min read
typescriptfrontenddx

After years of writing TypeScript across multiple production codebases — from food delivery apps to trading dashboards — I’ve collected a set of type system tricks that genuinely changed how I write code. These aren’t just cool party tricks. They’ve helped me catch real bugs before they shipped.

1. Discriminated Unions for API States

The most common pattern I see devs get wrong is modeling async state:

// ❌ This allows impossible states
type UserState = {
  loading: boolean;
  error: string | null;
  data: User | null;
};

// ✅ Only valid states can exist
type UserState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; error: string }
  | { status: "success"; data: User };

With discriminated unions, TypeScript narrows the type automatically in your switch/if blocks. No more data! non-null assertions or “why is error null when loading is false?” bugs.

2. Template Literal Types for Event Names

I use this heavily in design systems and event buses:

type ButtonVariant = "primary" | "secondary" | "danger";
type ButtonSize = "sm" | "md" | "lg";

// Generates: "primary-sm" | "primary-md" | "primary-lg" | "secondary-sm" | ...
type ButtonClass = `${ButtonVariant}-${ButtonSize}`;

// Or for analytics events
type Entity = "user" | "post" | "comment";
type Action = "created" | "updated" | "deleted";
type AnalyticsEvent = `${Entity}:${Action}`;
// "user:created" | "user:updated" | "post:created" | ...

function track(event: AnalyticsEvent, payload: Record<string, unknown>) {
  // ...
}

track("user:created", { userId: "123" }); // ✅
track("user:invented", { userId: "123" }); // ❌ Type error

This is so much better than plain string — you get autocomplete and typo protection for free.

3. Infer — Extract Types from Deep Structures

When working with third-party libraries, you often need to extract types they don’t export:

// Extract the element type from an array
type ArrayElement<T> = T extends (infer E)[] ? E : never;

// Extract resolved type from a Promise
type Awaited<T> = T extends Promise<infer R> ? R : T;

// Extract return type from any async function
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (
  ...args: any
) => Promise<infer R>
  ? R
  : never;

// Real usage: type-safe API responses without duplication
const fetchUser = async (id: string) => {
  const res = await fetch(`/api/users/${id}`);
  return res.json() as Promise<{ id: string; name: string; email: string }>;
};

type User = AsyncReturnType<typeof fetchUser>;
// { id: string; name: string; email: string }

No more maintaining duplicate type definitions alongside your fetch functions.

4. Branded Types to Prevent Primitive Confusion

This one saved us from a nasty production bug where we passed a userId where an orderId was expected — both were string:

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

function getUser(id: UserId) {
  /* ... */
}
function getOrder(id: OrderId) {
  /* ... */
}

const userId = "user_123" as UserId;
const orderId = "order_456" as OrderId;

getUser(userId); // ✅
getUser(orderId); // ❌ Type error — caught at compile time!

The runtime cost is zero. It’s purely a compile-time guardrail.

5. Satisfies Operator — Validate Without Widening

Added in TypeScript 4.9, this one is underused:

const palette = {
  red: [255, 0, 0],
  green: "#00ff00",
  blue: [0, 0, 255],
} satisfies Record<string, string | number[]>;

// TypeScript knows the exact types, not the widened type
palette.red.map((x) => x * 2); // ✅ TypeScript knows it's number[]
palette.green.toUpperCase(); // ✅ TypeScript knows it's string

// With 'as const' you'd lose method inference
// With explicit type annotation you'd lose narrowing
// satisfies gives you both

I use satisfies constantly for config objects, theme tokens, and route definitions.

The Bigger Picture

These patterns share a common theme: make impossible states unrepresentable. The more you encode your domain logic into the type system, the less you rely on runtime checks, defensive programming, and unit tests for things TypeScript can prove statically.

The goal isn’t to be clever — it’s to let the compiler do the hard work so you can focus on building features.

If you want to go deeper, I’d recommend the TypeScript Handbook on conditional types and Matt Pocock’s Total TypeScript course. Both leveled up my understanding significantly.

What TypeScript tricks have changed how you write code? Reach out — I’d love to hear.