TypeScript Best Practices That Actually Matter
TypeScript

TypeScript Best Practices That Actually Matter

Advanced TypeScript patterns I use daily to catch bugs early, improve code quality, and boost team productivity in enterprise projects.

1 month ago
10 min read

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

#TypeScript#Best Practices#Code Quality#DX

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.