There's a moment every JavaScript developer knows too well. You're three months into a project, everything seems fine, and then — boom — a Cannot read properties of undefined error in production. Users are angry. Your boss is calling. And you're staring at code you wrote yourself, wondering: what was I thinking?
I've been there. Multiple times. Until TypeScript finally saved me from myself.
But here's the thing — most developers use TypeScript like it's just JavaScript with type annotations bolted on. They slap : string and : number everywhere, fight with the compiler, and wonder why everyone keeps saying TypeScript is amazing. The real power of TypeScript isn't in basic type annotations. It's in the advanced type system features that most tutorials skip.
Today I want to show you the TypeScript patterns that genuinely changed how I write code.
The Problem With "Any" (And Why You're Probably Using It Wrong)
Let's start with the elephant in the room. If your TypeScript code has a lot of any, you're not really using TypeScript — you're using JavaScript with extra steps.
// ❌ This is just JavaScript in disguise
function processData(data: any): any {
return data.value * 2;
}
// ✅ This actually protects you
function processData(data: { value: number }): number {
return data.value * 2;
}
The moment you reach for any, ask yourself: what do I actually know about this data? Nine times out of ten, you know more than you think.
Discriminated Unions: The Pattern That Will Change Your Life
Here's the pattern I wish someone had shown me on day one. Imagine you're handling API responses that can either succeed or fail:
// Without discriminated unions — a mess
interface ApiResponse {
data?: User;
error?: string;
success: boolean;
}
// The consumer has to guess what's safe to access
function handleResponse(response: ApiResponse) {
if (response.success) {
console.log(response.data?.name); // Still needs ?. because TS doesn't know
}
}
Now with discriminated unions:
// ✅ TypeScript knows exactly what's available in each branch
type ApiResponse =
| { success: true; data: User }
| { success: false; error: string };
function handleResponse(response: ApiResponse) {
if (response.success) {
console.log(response.data.name); // No ?. needed — TypeScript KNOWS data exists
} else {
console.log(response.error); // TypeScript KNOWS error exists here
}
}
The discriminant (success in this case) lets TypeScript narrow the type automatically. No more optional chaining where you don't need it. No more runtime surprises.
I use this pattern for everything now: loading states, form validation results, payment processing — anything that has mutually exclusive states.
Template Literal Types: Making Strings Type-Safe
This one blew my mind when I first saw it. TypeScript can reason about string patterns, not just specific string values:
type EventName = `on${Capitalize<string>}`;
const validEvent: EventName = "onClick"; // ✅
const validEvent2: EventName = "onChange"; // ✅
const invalidEvent: EventName = "click"; // ❌ Error! Doesn't start with 'on'
Here's a practical example — type-safe CSS class builders:
type Color = 'red' | 'blue' | 'green';
type Size = 'sm' | 'md' | 'lg';
type ButtonClass = `btn-${Color}-${Size}`;
function getButtonClass(color: Color, size: Size): ButtonClass {
return `btn-${color}-${size}`;
}
const cls = getButtonClass('red', 'lg'); // Type: "btn-red-lg"
const bad = getButtonClass('purple', 'lg'); // ❌ Error! 'purple' is not a Color
I've used this to build type-safe event bus systems, API route definitions, and CSS utility class generators. The possibilities are genuinely endless.
The satisfies Operator: Your New Best Friend
Added in TypeScript 4.9, satisfies is criminally underused. Here's the problem it solves:
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
};
// If you use 'as const', you lose the ability to use string methods on apiUrl
// If you annotate the type, you lose the specific literal types
// satisfies gives you BOTH
type Config = {
apiUrl: string;
timeout: number;
retries: number;
};
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
} satisfies Config;
// config.apiUrl is still typed as string (not just unknown)
// But TypeScript validates it matches Config
// And you get autocomplete on the specific keys!
console.log(config.apiUrl.toUpperCase()); // ✅ Works — still a string
I now use satisfies for all my configuration objects. It's the perfect middle ground between type safety and type inference.
Mapped Types: Transforming Types Programmatically
This is where TypeScript starts feeling like actual metaprogramming:
// Make all properties optional
type Partial<T> = {
[K in keyof T]?: T[K];
};
// Make all properties required
type Required<T> = {
[K in keyof T]-?: T[K];
};
// Make all properties readonly
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
These are actually built into TypeScript, but understanding how they work lets you build your own:
// Create a type with only nullable versions of all properties
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
// Create a type where all functions become async
type Asyncify<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R
? (...args: A) => Promise<R>
: T[K];
};
interface UserService {
getUser(id: string): User;
deleteUser(id: string): boolean;
}
type AsyncUserService = Asyncify<UserService>;
// Result:
// {
// getUser(id: string): Promise<User>;
// deleteUser(id: string): Promise<boolean>;
// }
I used this exact pattern to automatically generate async versions of synchronous service interfaces when migrating a codebase from synchronous to async.
Conditional Types: TypeScript's Ternary for Types
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // false
Combined with infer, this becomes incredibly powerful:
// Extract the return type of any function
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : never;
// Extract element type from array
type ElementType<T extends any[]> =
T extends (infer E)[] ? E : never;
type StringArrayElement = ElementType<string[]>; // string
type NumberArrayElement = ElementType<number[]>; // number
Putting It All Together: A Real-World Example
Let me show you how these patterns combine in a real feature. Here's a type-safe API client:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiEndpoint = {
'/users': { GET: User[]; POST: User };
'/users/:id': { GET: User; PUT: User; DELETE: void };
'/posts': { GET: Post[]; POST: Post };
};
type EndpointMethod<
Path extends keyof ApiEndpoint,
Method extends keyof ApiEndpoint[Path]
> = ApiEndpoint[Path][Method];
async function apiCall<
Path extends keyof ApiEndpoint,
Method extends keyof ApiEndpoint[Path] & HttpMethod
>(
method: Method,
path: Path,
body?: unknown
): Promise<EndpointMethod<Path, Method>> {
const response = await fetch(path, { method, body: JSON.stringify(body) });
return response.json();
}
// Usage — fully type-safe!
const users = await apiCall('GET', '/users'); // Type: User[]
const newUser = await apiCall('POST', '/users', { name: 'Alice' }); // Type: User
The compiler will catch you if you try to call a method that doesn't exist for a given endpoint. No documentation needed — the types are the documentation.
The Mindset Shift
Here's what I've learned after years of TypeScript: stop thinking of types as constraints and start thinking of them as specifications.
When you write a type, you're not restricting what your code can do — you're specifying what your code should do. The compiler becomes your first line of defense, catching mistakes before they ever reach production.
The developers who struggle with TypeScript are fighting the compiler. The developers who love TypeScript have learned to use it as a thinking tool. Before I write a function now, I think about its types first. What goes in? What comes out? What are the failure modes? By the time I write the implementation, I've already thought through the design.
That Cannot read properties of undefined error that used to ruin my day? I genuinely can't remember the last time I saw it in production. TypeScript caught it at compile time, long before any user ever ran the code.
Want to level up your TypeScript skills? Drop a comment with the TypeScript pattern that's given you the most trouble — let's work through it together.
Top comments (0)