Advanced TypeScript Patterns for Large Codebases
I was about to push code that would've broken production. TypeScript caught it.
I'd mixed up a user ID with a product ID. Both are strings. JavaScript would've let it through. TypeScript said "nope, these are different types."
That's when I realized TypeScript's type system is way more powerful than I thought. Here are the patterns I actually use.
Branded Types: Prevent ID Mix-ups
This is the one that saved me. I had functions that took user IDs and product IDs:
// Bad: Both are just strings function getUser(id: string) { } function getProduct(id: string) { } const userId = "user_123" const productId = "prod_456" getUser(productId) // Oops! TypeScript doesn't catch this
Fixed it with branded types:
// Good: Different types even though both are strings type UserId = string & { readonly brand: unique symbol } type ProductId = string & { readonly brand: unique symbol } function getUser(id: UserId) { } function getProduct(id: ProductId) { } const userId = "user_123" as UserId const productId = "prod_456" as ProductId getUser(productId) // Error! TypeScript catches this
Now I can't accidentally mix them up. Saved me multiple times.
Discriminated Unions: Better Error Handling
I used to return errors like this:
// Bad: Have to check both fields function fetchUser(id: string): { data?: User, error?: string } { // ... } const result = fetchUser("123") if (result.error) { // Handle error } else if (result.data) { // Use data }
Problem: TypeScript doesn't know that if error exists, data doesn't (and vice versa).
Fixed with discriminated unions:
// Good: TypeScript knows exactly what's available type Success<T> = { status: 'success' data: T } type Failure = { status: 'error' error: string } type Result<T> = Success<T> | Failure function fetchUser(id: string): Result<User> { // ... } const result = fetchUser("123") if (result.status === 'success') { // TypeScript knows result.data exists console.log(result.data.name) } else { // TypeScript knows result.error exists console.log(result.error) }
Way better. TypeScript helps you handle both cases correctly.
Template Literal Types: Type-Safe Event Names
I had event handlers with string names. Easy to typo:
// Bad: Easy to make mistakes emitter.on('userCreated', handler) emitter.on('userCreted', handler) // Typo! Won't catch it
Fixed with template literal types:
// Good: TypeScript catches typos type EventName = `on${Capitalize<'click' | 'submit' | 'change'>}` function addEventListener(event: EventName, handler: Function) { // ... } addEventListener('onClick', handler) // OK addEventListener('onSubmit', handler) // OK addEventListener('onClik', handler) // Error! Typo caught
Catches typos at compile time instead of runtime.
Utility Types: Don't Repeat Yourself
I had a User type and needed a version without the password:
// Bad: Duplicating fields type User = { id: string email: string password: string name: string } type PublicUser = { id: string email: string name: string }
If I add a field to User, I have to remember to add it to PublicUser. I'll forget.
Fixed with Omit:
// Good: Derived from User type User = { id: string email: string password: string name: string } type PublicUser = Omit<User, 'password'>
Now PublicUser automatically updates when User changes.
Const Assertions: Stricter Types
I had a config object:
// Bad: TypeScript infers wide types const config = { env: 'production', // Type: string port: 3000 // Type: number } function setEnv(env: 'development' | 'production') { // ... } setEnv(config.env) // Error! string is not assignable to 'development' | 'production'
Fixed with const assertion:
// Good: TypeScript infers exact types const config = { env: 'production', port: 3000 } as const // Now env is type 'production', not string setEnv(config.env) // Works!
What I Don't Use
Decorators: Looked cool but made code harder to understand. Skipped them.
Namespace: Old pattern. Use ES modules instead.
Enums: Use union types instead. Simpler and more flexible.
// Instead of enum enum Status { Active, Inactive } // Use union type type Status = 'active' | 'inactive'
The Results
Before TypeScript:
- Caught bugs in production
- Spent hours debugging type mismatches
- Afraid to refactor
After TypeScript:
- Catch bugs at compile time
- Refactor with confidence
- IDE autocomplete is amazing
Cost: Learning curve, but worth it.
Should You Use TypeScript?
If you're building anything bigger than a weekend project, yes.
The initial setup takes time. You'll fight with types at first. But once you get it, you'll catch so many bugs before they hit production.
Start simple. Add types gradually. Don't try to use every advanced pattern on day one.
But do use branded types for IDs. That one alone is worth it.
Using TypeScript? I'd love to hear which patterns you find most useful. Hit me up on LinkedIn.