← Back to Blog

TypeScript Best Practices for 2025

TypeScript Best Practices for 2025

TypeScript has evolved significantly. Here are the patterns and practices I use in production projects to write better, more maintainable code.

1. Strict Mode is Non-Negotiable

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true
  }
}

2. Use Type Inference

Let TypeScript infer types when possible:

// ❌ Redundant
const name: string = "John";

// ✅ Better
const name = "John";

// ❌ Redundant
function add(a: number, b: number): number {
  return a + b;
}

// ✅ Better (return type inferred)
function add(a: number, b: number) {
  return a + b;
}

3. Discriminated Unions

Perfect for state management and API responses:

type ApiResponse<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function handleResponse<T>(response: ApiResponse<T>) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <Data data={response.data} />;
    case 'error':
      return <Error message={response.error} />;
  }
}

4. Utility Types

Master the built-in utility types:

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

// Pick specific properties
type UserPreview = Pick<User, 'id' | 'name'>;

// Omit properties
type UserWithoutEmail = Omit<User, 'email'>;

// Make all properties optional
type PartialUser = Partial<User>;

// Make all properties required
type RequiredUser = Required<User>;

// Make all properties readonly
type ReadonlyUser = Readonly<User>;

5. Generic Constraints

// Constrain to objects with id property
function findById<T extends { id: string }>(
  items: T[],
  id: string
): T | undefined {
  return items.find(item => item.id === id);
}

// Constrain to specific keys
function getProperty<T, K extends keyof T>(
  obj: T,
  key: K
): T[K] {
  return obj[key];
}

6. Template Literal Types

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = '/users' | '/posts' | '/comments';

// Generate all combinations
type ApiRoute = `${HttpMethod} ${Endpoint}`;
// Result: 'GET /users' | 'POST /users' | ... (12 combinations)

// CSS properties
type CSSProperty = `${'margin' | 'padding'}-${'top' | 'bottom' | 'left' | 'right'}`;
// Result: 'margin-top' | 'margin-bottom' | ...

7. Type Guards

// User-defined type guard
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'name' in obj
  );
}

// Usage
function processData(data: unknown) {
  if (isUser(data)) {
    // TypeScript knows data is User here
    console.log(data.name);
  }
}

8. Const Assertions

// Without const assertion
const colors = ['red', 'green', 'blue'];
// Type: string[]

// With const assertion
const colors = ['red', 'green', 'blue'] as const;
// Type: readonly ['red', 'green', 'blue']

// Object const assertion
const config = {
  api: 'https://api.example.com',
  timeout: 5000
} as const;
// All properties become readonly

9. Branded Types

Create distinct types from primitives:

type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };

function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }

const userId = '123' as UserId;
const postId = '456' as PostId;

getUser(userId);  // ✅ OK
getUser(postId);  // ❌ Error: Type 'PostId' is not assignable to 'UserId'

10. Avoid 'any'

Use 'unknown' instead:

// ❌ Bad
function process(data: any) {
  return data.value; // No type checking
}

// ✅ Good
function process(data: unknown) {
  if (typeof data === 'object' && data !== null && 'value' in data) {
    return (data as { value: string }).value;
  }
  throw new Error('Invalid data');
}

Conclusion

These patterns have saved me countless hours of debugging and made my code more maintainable. TypeScript's type system is powerful—use it to your advantage!