TypeScript has become the de facto standard for large-scale JavaScript applications. Here are the best practices to write better type-safe code in 2025.

1. Use Strict Mode

Always enable strict mode in tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true
  }
}

2. Prefer Interfaces for Object Shapes

Use interfaces for object shapes, types for unions/intersections:

// Good: Interface for object shape
interface User {
  id: string;
  name: string;
  email: string;
}

// Good: Type for union
type Status = 'pending' | 'approved' | 'rejected';

// Good: Type for intersection
type AdminUser = User & { role: 'admin' };

3. Use Discriminated Unions

Make type narrowing easier with discriminated unions:

type Result<T> =
  | { success: true; data: T }
  | { success: false; error: string };

function handleResult<T>(result: Result<T>) {
  if (result.success) {
    // TypeScript knows result.data exists
    console.log(result.data);
  } else {
    // TypeScript knows result.error exists
    console.error(result.error);
  }
}

4. Avoid any, Use unknown

Never use any, use unknown for truly unknown types:

// Bad
function processData(data: any) {
  return data.value;
}

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

// Better: Use type guards
function isDataWithValue(data: unknown): data is { value: string } {
  return typeof data === 'object' && data !== null && 'value' in data;
}

function processData(data: unknown) {
  if (isDataWithValue(data)) {
    return data.value; // Type-safe!
  }
  throw new Error('Invalid data');
}

5. Use const Assertions

Preserve literal types with as const:

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

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

// Extract union type
type Color = typeof colors[number]; // "red" | "green" | "blue"

6. Use Utility Types

Leverage built-in utility types:

interface User {
  id: string;
  name: string;
  email: string;
  password: string;
}

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

// Pick: Select specific properties
type PublicUser = Pick<User, 'id' | 'name' | 'email'>;

// Omit: Exclude specific properties
type UserWithoutPassword = Omit<User, 'password'>;

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

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

7. Use Generic Constraints

Constrain generics for type safety:

// Bad: Too permissive
function getValue<T>(obj: T, key: string) {
  return obj[key]; // Error: can't index T with string
}

// Good: Constrained generic
function getValue<T extends Record<string, unknown>>(
  obj: T,
  key: keyof T
): T[keyof T] {
  return obj[key];
}

// Usage
const user = { name: 'John', age: 30 };
const name = getValue(user, 'name'); // string
const age = getValue(user, 'age'); // number

8. Prefer Function Overloads for Complex Signatures

Use function overloads for better type inference:

// Function overloads
function format(value: string): string;
function format(value: number): string;
function format(value: Date): string;
function format(value: string | number | Date): string {
  if (value instanceof Date) {
    return value.toISOString();
  }
  return String(value);
}

// Usage
const str = format('hello'); // string
const num = format(42); // string
const date = format(new Date()); // string

9. Use Template Literal Types

Create powerful string types:

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiEndpoint = `/api/${string}`;
type FullEndpoint = `${HttpMethod} ${ApiEndpoint}`;

// Usage
const endpoint: FullEndpoint = 'GET /api/users';

10. Use satisfies Operator (TypeScript 4.9+)

Ensure values match types without widening:

// Without satisfies
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
}; // Type is inferred, might be too wide

// With satisfies
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
} satisfies {
  apiUrl: string;
  timeout: number;
}; // Type is checked but not widened

11. Avoid Type Assertions

Prefer type guards over type assertions:

// Bad: Type assertion
function processUser(data: unknown) {
  const user = data as User;
  return user.name; // Unsafe!
}

// Good: Type guard
function isUser(data: unknown): data is User {
  return (
    typeof data === 'object' &&
    data !== null &&
    'id' in data &&
    'name' in data &&
    'email' in data
  );
}

function processUser(data: unknown) {
  if (isUser(data)) {
    return data.name; // Type-safe!
  }
  throw new Error('Invalid user data');
}

12. Use branded Types for Nominal Typing

Create distinct types for the same underlying type:

type UserId = string & { readonly brand: unique symbol };
type ProductId = string & { readonly brand: unique symbol };

function createUserId(id: string): UserId {
  return id as UserId;
}

function createProductId(id: string): ProductId {
  return id as ProductId;
}

// Usage
const userId = createUserId('123');
const productId = createProductId('123');

// Type error: can't mix UserId and ProductId
function getUser(id: UserId) { /* ... */ }
getUser(productId); // Error!

13. Use const for Immutable Data

Prefer const for immutable references:

// Bad: Mutable array
let items = [1, 2, 3];
items.push(4); // Mutation allowed

// Good: Readonly array
const items = [1, 2, 3] as const;
// items.push(4); // Error: readonly

// Good: Readonly type
const items: readonly number[] = [1, 2, 3];

14. Use Index Signatures Carefully

Be specific with index signatures:

// Bad: Too permissive
interface Config {
  [key: string]: any;
}

// Good: More specific
interface Config {
  [key: string]: string | number | boolean;
}

// Better: Use Record utility type
type Config = Record<string, string | number | boolean>;

// Best: Be explicit when possible
interface Config {
  apiUrl: string;
  timeout: number;
  retries: number;
  [key: string]: string | number; // Only for truly dynamic keys
}

15. Use never for Exhaustiveness Checking

Ensure all cases are handled:

type Status = 'pending' | 'approved' | 'rejected';

function handleStatus(status: Status) {
  switch (status) {
    case 'pending':
      return 'Processing...';
    case 'approved':
      return 'Approved!';
    case 'rejected':
      return 'Rejected.';
    default:
      // TypeScript ensures all cases are handled
      const exhaustive: never = status;
      return exhaustive;
  }
}

16. Use Mapped Types for Transformations

Create new types from existing ones:

type Optional<T> = {
  [K in keyof T]?: T[K];
};

type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

17. Prefer Composition Over Inheritance

Use composition with types:

// Bad: Inheritance
class User extends BaseEntity {
  // ...
}

// Good: Composition
type User = BaseEntity & {
  name: string;
  email: string;
};

18. Use readonly for Immutability

Mark properties as readonly when appropriate:

interface Config {
  readonly apiUrl: string;
  readonly timeout: number;
}

const config: Config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
};

// config.apiUrl = '...'; // Error: readonly

19. Use as const with Enums

Prefer as const objects over enums:

// Bad: Enum
enum Status {
  Pending = 'pending',
  Approved = 'approved',
  Rejected = 'rejected',
}

// Good: const object
const Status = {
  Pending: 'pending',
  Approved: 'approved',
  Rejected: 'rejected',
} as const;

type Status = typeof Status[keyof typeof Status];

20. Document Complex Types

Add JSDoc comments for complex types:

/**
 * Represents a user in the system.
 * 
 * @property id - Unique identifier
 * @property name - User's full name
 * @property email - User's email address
 * @property role - User's role in the system
 */
interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

Tools and Resources

Type Checking

  • tsc --noEmit - Type check without emitting
  • ESLint with TypeScript rules
  • typescript-eslint - Enhanced linting

Type Utilities

  • type-fest - Useful type utilities
  • ts-toolbelt - Type utilities library
  • utility-types - TypeScript utility types

Testing

  • tsd - Type testing library
  • @typescript-eslint/parser - ESLint parser

Conclusion

Following these best practices will help you:

  • Write safer code: Catch errors at compile time
  • Improve maintainability: Clear, self-documenting types
  • Enhance developer experience: Better IDE support
  • Reduce bugs: Type safety prevents common errors

Remember: TypeScript is a tool to help you write better JavaScript. Use it wisely, but don’t over-engineer. Sometimes, simpler is better.

Happy typing! 🎯