TypeScript Done Right
After writing TypeScript in enterprise projects for 5+ years, I've learned which practices actually prevent bugs versus which just add noise. Here are the patterns that made the biggest difference.
1. Strict Mode is Non-Negotiable
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
Why? These caught hundreds of bugs before they reached production.
2. Type Inference Over Explicit Types
// ❌ Don't: Redundant type annotations
const name: string = "John";
const age: number = 30;
// ✅ Do: Let TypeScript infer
const name = "John";
const age = 30;
// ✅ Do: Annotate when needed
function getUser(id: string): Promise {
return fetchUser(id);
}
3. Discriminated Unions for State
Instead of booleans and optionals, use discriminated unions:
// ❌ Don't: Unclear state
interface RequestState {
loading: boolean;
data?: User;
error?: Error;
}
// ✅ Do: Impossible states impossible
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: Error };
// Perfect type narrowing
if (state.status === 'success') {
console.log(state.data.name); // ✅ No optional chaining needed
}
4. Branded Types for Domain Safety
// Prevent mixing different ID types
type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }
// TypeScript prevents this mistake:
const userId = '123' as UserId;
getPost(userId); // ❌ Error: UserId is not assignable to PostId
This prevented dozens of bugs where we accidentally mixed IDs.
5. Const Assertions for Literals
// ❌ Type is string[]
const colors = ['red', 'green', 'blue'];
// ✅ Type is readonly ['red', 'green', 'blue']
const colors = ['red', 'green', 'blue'] as const;
// Enables better type checking
type Color = typeof colors[number]; // 'red' | 'green' | 'blue'
6. Utility Types are Your Friends
interface User {
id: string;
name: string;
email: string;
password: string;
}
// Create user without password
type PublicUser = Omit;
// Update only some fields
type UserUpdate = Partial>;
// Make everything readonly
type ImmutableUser = Readonly;
7. Generic Constraints for Flexibility
// ❌ Too permissive
function getValue(obj: T, key: string) {
return obj[key]; // Error!
}
// ✅ Constrained and safe
function getValue(obj: T, key: K): T[K] {
return obj[key]; // ✅ Type-safe
}
const user = { name: 'John', age: 30 };
getValue(user, 'name'); // ✅ Returns string
getValue(user, 'invalid'); // ❌ Error at compile time
8. Template Literal Types
// Create dynamic types
type EventName = `on${Capitalize<'click' | 'hover' | 'focus'>}`;
// Result: 'onClick' | 'onHover' | 'onFocus'
// Type-safe routing
type Route = '/users' | '/posts' | '/settings';
type RouteWithId = `${Route}/${string}`;
// Result: '/users/123' is valid, '/invalid/123' is not
9. Never Type for Exhaustiveness
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.size ** 2;
case 'triangle':
return (shape.base * shape.height) / 2;
default:
// If we add a new shape and forget to handle it,
// TypeScript will error here
const _exhaustive: never = shape;
return _exhaustive;
}
}
10. Mapped Types for Transformations
// Make all properties optional and nullable
type Nullable = {
[K in keyof T]: T[K] | null;
};
// Add event handlers for all props
type WithEventHandlers = {
[K in keyof T as `on${Capitalize}Change`]: (value: T[K]) => void;
};
type UserHandlers = WithEventHandlers;
// Result: { onIdChange: (value: string) => void; onNameChange: ... }
Real Impact
These practices reduced our production bugs by 60% and improved developer velocity by making refactoring much safer.
Common Mistakes to Avoid
1. Using any - defeats the purpose of TypeScript
2. Type assertions everywhere - as should be rare
3. Ignoring errors with @ts-ignore - fix the root cause
4. Over-engineering types - keep it simple until you need complexity
5. Not updating types when changing code - types should reflect reality
Tools That Help
- ts-reset - Better default types
- type-fest - Utility types library
- ts-pattern - Pattern matching for TypeScript
- zod - Runtime validation + TypeScript types
The Bottom Line
TypeScript isn't about writing more code—it's about catching bugs at compile time instead of 3 AM in production. These patterns have saved my team countless hours of debugging.
What TypeScript patterns have helped you most? Share your favorites!
Tags
Share this article:
Found this helpful?
I share more insights like this regularly. Check out my other articles or get in touch for consulting work.