Engineering

TypeScript Patterns That Changed How I Write Code

Five TypeScript patterns I use constantly, discriminated unions, template literal types, const assertions, branded types, and satisfies, and why they matter.

TypeScript code with complex type annotations highlighted
Thomi Jasir · Aug 12, 2024
#typescript#patterns#type-safety#programming

I used to write TypeScript the same way I wrote JavaScript. Slap some type annotations on the function parameters, call it a day, and feel good about it. Then I started using Rust, and everything changed.

Rust taught me that a type system isn’t just for documentation. It’s for making wrong states impossible to write. When I came back to TypeScript with that mindset, I found five patterns I had been completely ignoring. They don’t require any library. They’re built right into TypeScript. And they’ve stopped real bugs from ever reaching production.

1. Discriminated Unions for State Machines

Most async state has three possible states: loading, success, error. The naive approach uses optional fields:

// Problematic: all combinations are valid, including nonsensical ones
type FetchState = {
  loading: boolean;
  data?: User;
  error?: Error;
};

This allows { loading: true, data: someUser, error: someError } — a logically impossible state. I’ve seen this exact bug hit production. Discriminated unions make impossible states unrepresentable:

type FetchState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: Error };

Now TypeScript narrows correctly in each branch. You can’t access data without first checking status === 'success'. The compiler enforces it, not your memory.

2. Template Literal Types for String Constraints

Instead of accepting any string for values that have a known pattern:

// onLoad, onClick, onChange...
type EventName = `on${Capitalize<string>}`;
type CSSUnit = `${number}px` | `${number}rem` | `${number}%`;

type APIRoute = `/api/${string}`;

These types document intent and catch typos at compile time. No more passing "onclick" when the code expects "onClick". The compiler tells you immediately.

3. Const Assertions for Inferred Literals

const ROLES = ["admin", "member", "viewer"] as const;
type Role = (typeof ROLES)[number]; // 'admin' | 'member' | 'viewer'

// Now this is a type error:
// TS Error: not assignable to type Role
const role: Role = "superadmin";

as const freezes the array as a tuple of literal types, letting you derive the union type automatically. When you add a new role to the array, the type updates everywhere it’s used. One source of truth. No manual sync needed.

4. Branded Types for Domain Safety

TypeScript’s structural type system means UserId and ProjectId are both just string. They’re interchangeable, which is a problem. Branding creates nominal types:

type Brand<T, B> = T & { readonly _brand: B }
type UserId = Brand<string, 'UserId'>
type ProjectId = Brand<string, 'ProjectId'>

function getUser(id: UserId): Promise<User> { ... }

const projectId = 'proj_123' as ProjectId
getUser(projectId) // TS Error: ProjectId is not assignable to UserId

This pattern has caught real bugs in our codebase. IDs from different entities were being accidentally swapped. The compiler now prevents it entirely. No test required.

5. The satisfies Operator

Added in TypeScript 4.9, satisfies validates that a value matches a type while preserving the most specific type:

type Theme = Record<string, string>;

// Without satisfies — colors is inferred as Theme, losing specifics
const colors: Theme = { primary: "#4ade80", secondary: "#22d3ee" };
colors.primary; // string, not '#4ade80'

// With satisfies — validation + specific inference
const colors = {
  primary: "#4ade80",
  secondary: "#22d3ee"
} satisfies Theme;
colors.primary; // '#4ade80' (literal type preserved)

Use satisfies when you want type checking without widening. It’s a small keyword, but it closes a gap that used to force you to choose between safety and specificity.

Q&A

Q: Do I need to learn all five of these at once? A: No. Start with discriminated unions. That one pattern alone will change how you model state. Once it clicks, the others start feeling obvious. Pick them up one at a time as you hit the problem they solve.

Q: Are these patterns only useful for large codebases? A: Not at all. I started using branded types on a solo project after I accidentally passed a userId where a postId was expected and debugged it for two hours. Small codebase, real pain. The patterns scale down just as well.

Q: What about runtime? Does this add any overhead? A: Zero. These are compile-time-only constructs. The branded type _brand field doesn’t exist at runtime. Template literal types are erased. Const assertions produce the same JavaScript as without them. All the safety is free.

Q: I’ve been writing TypeScript for years and never needed these. Why should I start now? A: You probably have needed them, just without realizing it. If you’ve ever written a defensive runtime check that “should never happen,” that’s a sign the type system could have prevented it. These patterns move those checks from runtime to compile time, where they’re free and always run.

Q: Can I use these patterns with existing codebases or do I need to refactor everything? A: You can introduce them gradually. Add a discriminated union to one state object. Brand one type of ID. The compiler will start guiding you. No big-bang refactor needed.

Conclusion

I wasted a lot of time treating TypeScript as “JavaScript with documentation.” It’s not. It’s a tool for making certain categories of bugs structurally impossible to write.

None of these five patterns require a library or a framework change. They’re in the language, waiting. The only shift is deciding to use the compiler as a collaborator instead of a complaint machine. Once you make that shift, you’ll find yourself writing less defensive code and shipping with more confidence.

The compiler won’t let certain bugs exist. That’s the point.

Comments

Loading comments...