7 TypeScript Patterns That Saved My Sanity
Learn: 7 TypeScript Patterns That Saved My Sanity
Welcome to TopperBlog! 👋
I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.
🎯 What I Write About:
• AI/ML Engineering & LLMs
• Web3 & Blockchain Development
• System Design & Architecture
• Interview Preparation (FAANG)
• Freelancing & Remote Work
• Modern Tech Stacks (Next.js, React, Rust, TypeScript)
• Performance Optimization & Best Practices
💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.
📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.
🌐 Let's connect and grow together in this amazing tech journey!
#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering
7 TypeScript Patterns That Saved My Sanity
Type safety best practices that actually work in the real world
The 3 AM Production Bug That Changed Everything
Picture this: It's 3 AM, my phone is buzzing like an angry hornet, and our production app is throwing errors that make absolutely no sense. Users can't check out. Revenue is bleeding. And the culprit? A simple typo in a property name that JavaScript happily let through, only to explode spectacularly in production.
I'd been writing JavaScript for years, convinced that TypeScript was just extra ceremony for people who couldn't remember their own code. That night, staring at my laptop screen with a cold cup of coffee, I realized I was the problem. Not the language.
The next morning, I started migrating our codebase to TypeScript. But not just any TypeScript—I'm talking about actually using TypeScript's type system instead of slapping any on everything and calling it a day. What I discovered were seven patterns that transformed my code from a ticking time bomb into something I could actually trust.
The Story: From Type-Skeptic to Type-Evangelist
I used to be that developer. You know the one—rolling their eyes at TypeScript advocates, muttering about "over-engineering" and "just write tests." Then I joined a startup where our codebase had grown from a weekend project to a 200,000-line monstrosity.
The breaking point wasn't even a big refactor. We simply renamed a property in our User object from userId to id. Sounds simple, right? We updated the backend, deployed, and watched in horror as 47 different places in the frontend started failing. Places we didn't even know existed. Places that weren't covered by tests because, well, who tests everything?
That's when I got serious about TypeScript. Not the "add types to make the compiler happy" TypeScript, but the "make impossible states impossible" TypeScript. Here are the seven patterns that literally saved my sanity (and probably my job).
Technical Deep Dive
Problem Breakdown
Before we dive into solutions, let's talk about the real problems TypeScript solves:
- Runtime errors from typos and refactoring - The silent killers that only show up in production
- Unclear function contracts - What does this function actually expect? Who knows!
- Impossible states - When your app can be in states that shouldn't exist
- Poor autocomplete - Guessing property names like it's 2005
- Refactoring fear - That paralyzing dread when you need to change something fundamental
The patterns below address these issues head-on, with real code you can use today.
Pattern 1: Discriminated Unions (The State Machine Savior)
The Problem: Ever had a loading state with data that's sometimes there and sometimes not? Or an error state where you're not sure if the error object exists?
// The nightmare version
interface ApiState {
loading: boolean;
data?: User[];
error?: string;
}
// What happens here? 🤷♂️
const state: ApiState = {
loading: false,
data: undefined,
error: undefined
};
This is a disaster waiting to happen. You can have loading: true with data present. Or loading: false with no data and no error. These are impossible states that cause bugs.
The Solution:
// The sane version
type ApiState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function handleState(state: ApiState) {
switch (state.status) {
case 'idle':
return 'Ready to fetch';
case 'loading':
return 'Loading...';
case 'success':
// TypeScript KNOWS data exists here
return `Loaded ${state.data.length} users`;
case 'error':
// TypeScript KNOWS error exists here
return `Error: ${state.error}`;
}
}
This pattern makes impossible states impossible. You literally cannot create an ApiState that's loading with an error. TypeScript won't let you. It's beautiful.
Pattern 2: Branded Types (The ID Mix-Up Preventer)
The Problem: All IDs are strings, but not all strings should be used as IDs.
// Looks fine, compiles fine, breaks in production
function getUser(userId: string) { /* ... */ }
function getPost(postId: string) { /* ... */ }
const userId = "user_123";
const postId = "post_456";
getUser(postId); // Oops! TypeScript says this is fine 😱
The Solution:
// Branded types to the rescue
type UserId = string & { readonly brand: unique symbol };
type PostId = string & { readonly brand: unique symbol };
function createUserId(id: string): UserId {
return id as UserId;
}
function createPostId(id: string): PostId {
return id as PostId;
}
function getUser(userId: UserId) { /* ... */ }
function getPost(postId: PostId) { /* ... */ }
const userId = createUserId("user_123");
const postId = createPostId("post_456");
getUser(postId); // ❌ TypeScript error! Can't use PostId as UserId
This saved me from a bug where we were accidentally using organization IDs as user IDs. The functions compiled fine, but users were seeing other organizations' data. Not great for a B2B SaaS product.
Pattern 3: Const Assertions (The Configuration Champion)
The Problem: Configuration objects that lose their specificity.
// TypeScript sees this as: { method: string, headers: object }
const config = {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
};
// Later, someone does this and TypeScript is cool with it
config.method = 'INVALID_METHOD'; // No error!
The Solution:
// Use 'as const' for literal types
const config = {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
} as const;
// Now TypeScript knows method is literally 'GET', not just string
type Method = typeof config.method; // Type: 'GET'
// This creates an error now
config.method = 'POST'; // ❌ Cannot assign to 'method' because it is a read-only property
// Perfect for route definitions
const ROUTES = {
home: '/',
users: '/users',
profile: '/profile/:id'
} as const;
type RouteKey = keyof typeof ROUTES; // 'home' | 'users' | 'profile'
Pattern 4: Template Literal Types (The String Pattern Enforcer)
The Problem: CSS classes, API endpoints, and other string patterns that need validation.
// Any string works, even garbage
function setColor(color: string) { /* ... */ }
setColor('reed'); // Meant 'red', but TypeScript doesn't care
The Solution:
// Enforce patterns at compile time
type Color = 'red' | 'green' | 'blue';
type Shade = '100' | '200' | '300';
type ColorClass = `text-${Color}-${Shade}`;
function setColor(color: ColorClass) { /* ... */ }
setColor('text-red-100'); // ✅ Valid
setColor('text-reed-100'); // ❌ TypeScript error
setColor('text-red-400'); // ❌ TypeScript error
// API versioning
type ApiVersion = 'v1' | 'v2' | 'v3';
type Endpoint = 'users' | 'posts' | 'comments';
type ApiPath = `/api/${ApiVersion}/${Endpoint}`;
function fetchApi(path: ApiPath) { /* ... */ }
fetchApi('/api/v2/users'); // ✅ Valid
fetchApi('/api/v4/users'); // ❌ TypeScript error
This pattern caught a bug where we had inconsistent API versioning across our codebase. Some calls used v1, others used version1, and a few creative souls used api-v1.
Pattern 5: Utility Types for Transformation (The Refactoring Friend)
The Problem: Duplicating types or manually maintaining variations of the same type.
// Original type
interface User {
id: string;
name: string;
email: string;
password: string;
}
// Manually creating variations (maintenance nightmare)
interface UserResponse {
id: string;
name: string;
email: string;
// Forgot to remove password - security issue!
password: string;
}
The Solution:
interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
// Omit sensitive fields
type UserResponse = Omit<User, 'password'>;
// Pick only what you need
type UserPreview = Pick<User, 'id' | 'name'>;
// Make everything optional for updates
type UserUpdate = Partial<User>;
// Make everything required (opposite of Partial)
type CompleteUser = Required<User>;
// Make everything readonly
type ImmutableUser = Readonly<User>;
// Combine them!
type UserUpdateRequest = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
// Now when User changes, all these types update automatically
I can't tell you how many times this saved me during refactoring. Change one interface, and all the derived types update automatically. It's like magic, but better because it actually works.
Pattern 6: Type Guards (The Runtime Safety Net)
The Problem: TypeScript's types disappear at runtime, so you need runtime checks.
// Dangerous assumption
function processData(data: unknown) {
// TypeScript doesn't know what data is
console.log(data.name); // ❌ Error: Object is of type 'unknown'
// This "works" but is dangerous
console.log((data as any).name); // Compiles, crashes at runtime
}
The Solution:
// Create type guards
interface User {
id: string;
name: string;
email: string;
}
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'name' in obj &&
'email' in obj &&
typeof (obj as User).id === 'string' &&
typeof (obj as User).name === 'string' &&
typeof (obj as User).email === 'string'
);
}
function processData(data: unknown) {
if (isUser(data)) {
// TypeScript KNOWS data is User here
console.log(data.name); // ✅ Safe and typed
console.log(data.email); // ✅ Autocomplete works!
} else {
console.log('Invalid user data');
}
}
// For arrays
function isUserArray(arr: unknown): arr is User[] {
return Array.isArray(arr) && arr.every(isUser);
}
// Using with API responses
async function fetchUsers(): Promise<User[]> {
const response = await fetch('/api/users');
const data = await response.json();
if (!isUserArray(data)) {
throw new Error('Invalid user data from API');
}
return data; // TypeScript knows this is User[]
}
This pattern saved me when our backend team changed the API response format without telling anyone. Instead of silent failures, we got clear errors at the boundary.
Pattern 7: Mapped Types (The DRY Principle Champion)
The Problem: Creating similar types over and over with slight variations.
// Tedious and error-prone
interface UserFormData {
name: string;
email: string;
age: number;
}
interface UserFormErrors {
name?: string;
email?: string;
age?: string;
}
interface UserFormTouched {
name?: boolean;
email?: boolean;
age?: boolean;
}
// If you add a field to UserFormData, you have to update 3 places!
The Solution:
interface UserFormData {
name: string;
email: string;
age: number;
}
// Generate error types automatically
type FormErrors<T> = {
[K in keyof T]?: string;
};
// Generate touched types automatically
type FormTouched<T> = {
[K in keyof T]?: boolean;
};
// Generate loading states for each field
type FormLoading<T> = {
[K in keyof T]?: boolean;
};
// Use them
type UserFormErrors = FormErrors<UserFormData>;
type UserFormTouched = FormTouched<UserFormData>;
type UserFormLoading = FormLoading<UserFormData>;
// Advanced: Make specific fields required
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
type UserWithRequiredEmail = RequireFields<Partial<UserFormData>, 'email'>;
// Result: { name?: string; email: string; age?: number; }
// Convert all fields to promises (useful for async validation)
type AsyncValidation<T> = {
[K in keyof T]: Promise<T[K]>;
};
This pattern is a game-changer for forms, API clients, and any time you have parallel data structures.
Quick Comparison Table
| Pattern | Best For | Complexity | Impact |
| Discriminated Unions | State machines, API states | Low | High - Eliminates impossible states |
| Branded Types | Preventing ID mix-ups | Medium | High - Catches subtle bugs |
| Const Assertions | Configuration, constants | Low | Medium - Better autocomplete |
| Template Literals | String patterns, CSS classes | Medium | Medium - Compile-time validation |
| Utility Types | Type transformations | Low | High - Reduces duplication |
| Type Guards | Runtime validation | Medium | High - Runtime safety |
| Mapped Types | Generating related types | High | High - Ultimate DRY |
Key Takeaways
- Discriminated unions make impossible states impossible - Use them for any state machine or API state management
- Branded types prevent ID confusion - Especially critical in multi-tenant applications
- Const assertions preserve literal types - Perfect for configuration objects and route definitions
- Template literal types enforce string patterns - Catch typos in CSS classes, API paths, and more at compile time
- Utility types reduce duplication - Let TypeScript derive types instead of manually maintaining them
- Type guards bridge compile-time and runtime - Essential for validating external data
- Mapped types are the ultimate DRY tool - Generate related types automatically
The common thread? These patterns make TypeScript work for you, not against you. They catch bugs at compile time that would otherwise ruin your weekend (or your sleep).
FAQ
Q: Won't all these types slow down my development?
A: Actually, the opposite. Yes, there's an upfront cost to setting up these patterns, but you'll save hours (or days) in debugging time. I spend way less time in the debugger now because TypeScript catches issues before I even run the code. Plus, autocomplete becomes so good that you'll write code faster.
Q: Should I use all these patterns in every project?
A: No! Start with discriminated unions and utility types—they have the best effort-to-value ratio. Add the others as you encounter problems they solve. A small script doesn't need branded types, but a large application with multiple ID types absolutely does.
Q: What about performance? Do all these types slow down my app?
A: TypeScript types are completely erased at compile time. They have zero runtime performance impact. The only "cost" is slightly longer compile times, which is negligible for most projects.
Q: How do I convince my team to adopt these patterns?
A: Start small. Introduce one pattern at a time when it solves a real problem your team is facing. Had a bug from mixing up IDs? Show them branded types. Dealing with complex state? Demo discriminated unions. Nothing convinces developers like solving their actual pain points.
Q: Are these patterns compatible with React/Vue/Angular?
A: Absolutely! These are pure TypeScript patterns that work with any framework. In fact, discriminated unions are particularly powerful with React's state management, and type guards are essential for validating props from external sources.
Q: What if I'm maintaining a JavaScript codebase?
A: You can adopt TypeScript gradually. Start by renaming .js files to .ts and adding these patterns to new code. Use JSDoc comments to get some type checking in JavaScript files. The migration doesn't have to be all-or-nothing.
Conclusion: Types Are Love Letters to Your Future Self
Here's the truth: that 3 AM production bug wasn't the last time I got woken up by a preventable error. But it was the last time I got woken up by a type-related error.
These seven patterns aren't just about writing "better" TypeScript. They're about sleeping better at night. They're about refactoring with confidence instead of fear. They're about onboarding new developers who can understand your code's contracts without reading every line.
The best code is code you can trust. And trust, in software development, comes from making invalid states unrepresentable, making errors impossible to ignore, and making the compiler your ally instead of your adversary.
Start with one pattern. Pick the one that solves your biggest pain point right now. Implement it. Feel the relief when TypeScript catches a bug you would have missed. Then come back for the next one.
Your future self—the one who isn't debugging at 3 AM—will thank you.
Now go forth and make impossible states impossible.