TypeScript Best Practices

Difficulty: Advanced

TypeScript best practices are guidelines that help teams write maintainable, type-safe code. Adopting these practices from the start prevents technical debt and makes the codebase easier to navigate for new team members. The most important principle is to leverage TypeScript's type system fully - use strict mode, avoid `any`, prefer interfaces for object shapes, and use generics to create reusable, type-safe abstractions.

Avoiding `any` is the cornerstone of TypeScript best practices. Every `any` type creates a hole in your type safety net where bugs can hide. Use `unknown` instead of `any` when you do not know the type - it forces you to narrow the type before using the value. Use specific union types (`string | number`) instead of `any` when the value can be one of several known types. In legacy codebases, track `any` usage with the `noImplicitAny` flag and gradually eliminate explicit `any` annotations.

Code organization best practices include separating types into dedicated files or co-locating them with their related code, using barrel exports (index.ts) to simplify imports, and leveraging path aliases for clean import paths. Prefer interfaces over type aliases for object shapes because interfaces are extendable and produce clearer error messages. Use type aliases for unions, intersections, mapped types, and conditional types - cases where interfaces cannot be used.

Migrating a JavaScript project to TypeScript should be done incrementally. Start by renaming .js files to .ts one at a time, adding `allowJs: true` to tsconfig.json so JavaScript files can coexist with TypeScript. Enable strict mode flags one at a time rather than all at once. Begin with `noImplicitAny`, then add `strictNullChecks`, then the rest. Use `@ts-expect-error` comments to suppress known issues temporarily, and track them as technical debt to resolve later.

Code examples

Avoiding any with unknown and Type Guards

// BAD: Using 'any' - no type safety
function processAny(data: any): string {
  return data.name.toUpperCase(); // No error, but crashes if data has no name
}

// GOOD: Using 'unknown' with type guards
function processUnknown(data: unknown): string {
  if (typeof data === 'object' && data !== null && 'name' in data) {
    const obj = data as { name: string };
    return obj.name.toUpperCase();
  }
  return 'Unknown';
}

// BETTER: Using generics with constraints
function processTyped<T extends { name: string }>(data: T): string {
  return data.name.toUpperCase();
}

console.log(processUnknown({ name: 'alice' }));
console.log(processUnknown(42));
console.log(processUnknown(null));
console.log(processTyped({ name: 'bob', age: 25 }));

Using 'unknown' forces you to check the type before accessing properties. Using generics with constraints provides compile-time safety without runtime checks.

Interface vs Type Alias Best Practices

// Use INTERFACES for object shapes - they are extendable
interface User {
  id: number;
  name: string;
}

interface Admin extends User {
  permissions: string[];
}

// Use TYPE ALIASES for unions, intersections, and computed types
type Status = 'active' | 'inactive' | 'pending';
type Nullable<T> = T | null;
type UserOrAdmin = User | Admin;

// Demonstrate
const user: User = { id: 1, name: 'Alice' };
const admin: Admin = { id: 2, name: 'Bob', permissions: ['read', 'write'] };
const status: Status = 'active';
const nullableUser: Nullable<User> = null;

console.log(`User: ${user.name}`);
console.log(`Admin: ${admin.name}, permissions: ${admin.permissions.join(', ')}`);
console.log(`Status: ${status}`);
console.log(`Nullable user: ${nullableUser}`);

// Interfaces can be declaration-merged (augmented)
// interface User { email: string; } // Adds email to User
// Type aliases cannot be merged
console.log('Interfaces: extendable, mergeable');
console.log('Type aliases: unions, intersections, mapped types');

Use interfaces for object shapes (extendable, mergeable, clearer errors). Use type aliases for unions, intersections, and utility types that interfaces cannot express.

Incremental Migration Pattern

// Simulating a migration tracking system
interface MigrationFile {
  name: string;
  status: 'js' | 'ts-loose' | 'ts-strict';
  anyCount: number;
}

function getMigrationProgress(files: MigrationFile[]): {
  total: number;
  migrated: number;
  strict: number;
  anyCount: number;
  progress: string;
} {
  const total = files.length;
  const migrated = files.filter(f => f.status !== 'js').length;
  const strict = files.filter(f => f.status === 'ts-strict').length;
  const anyCount = files.reduce((sum, f) => sum + f.anyCount, 0);
  const percent = Math.round((migrated / total) * 100);
  return { total, migrated, strict, anyCount, progress: `${percent}%` };
}

const files: MigrationFile[] = [
  { name: 'utils.ts', status: 'ts-strict', anyCount: 0 },
  { name: 'api.ts', status: 'ts-loose', anyCount: 5 },
  { name: 'helpers.js', status: 'js', anyCount: 0 },
  { name: 'types.ts', status: 'ts-strict', anyCount: 0 },
  { name: 'legacy.ts', status: 'ts-loose', anyCount: 12 }
];

const progress = getMigrationProgress(files);
console.log(`Total files: ${progress.total}`);
console.log(`Migrated to TS: ${progress.migrated}`);
console.log(`Strict mode: ${progress.strict}`);
console.log(`Remaining 'any': ${progress.anyCount}`);
console.log(`Overall progress: ${progress.progress}`);

Incremental migration tracks files across three stages: JavaScript, loose TypeScript (with any), and strict TypeScript (no any, all flags enabled).

Key points

Concepts covered

best practices, coding conventions, migration tips, type safety, code organization, strict typing, avoid any