TypeScript Best Practices Writing Cleaner, Safer Code

Learn key best practices for writing clean and type-safe TypeScript code.

6 min read

TypeScript Best Practices: Writing Cleaner, Safer Code

TypeScript has become an indispensable tool for many JavaScript developers, offering static typing, improved tooling, and enhanced code quality. However, to truly harness the power of TypeScript, it's crucial to adhere to best practices that promote clean, maintainable, and type-safe code. In this comprehensive guide, we'll explore key TypeScript best practices that will elevate your development experience and code quality.

Leverage TypeScript's Type System

TypeScript's primary strength lies in its robust type system. Embrace it fully to catch errors early and improve code readability.

Use Specific Types

Avoid using

any
whenever possible. Instead, use specific types or create custom types:

// Avoid
function processData(data: any): any {
  // ...
}

// Prefer
interface UserData {
  id: number;
  name: string;
  email: string;
}

function processUserData(data: UserData): string {
  return `User ${data.name} (${data.email})`;
}

Utilize Union Types and Type Aliases

Union types and type aliases can make your code more expressive:

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

interface Task {
  id: number;
  status: Status;
  title: string;
}

function updateTaskStatus(task: Task, newStatus: Status): Task {
  return { ...task, status: newStatus };
}

Embrace Interfaces and Type Aliases

Interfaces and type aliases help create reusable, descriptive types:

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

type AdminUser = User & {
  role: 'admin';
  permissions: string[];
};

function isAdmin(user: User): user is AdminUser {
  return 'role' in user && user.role === 'admin';
}

Use Generics for Flexible, Reusable Code

Generics allow you to write functions and classes that work with various types while maintaining type safety:

function identity<T>(arg: T): T {
  return arg;
}

const num = identity(42);  // Type is number
const str = identity("Hello");  // Type is string

class Queue<T> {
  private data: T[] = [];

  push(item: T) {
    this.data.push(item);
  }

  pop(): T | undefined {
    return this.data.shift();
  }
}

const numberQueue = new Queue<number>();
numberQueue.push(10);
const item = numberQueue.pop();  // Type is number | undefined

Leverage Strict Null Checks

Enable

strictNullChecks
in your
tsconfig.json
to catch null and undefined errors:

{
  "compilerOptions": {
    "strictNullChecks": true
  }
}

This forces you to handle potential null or undefined values explicitly:

function getLength(str: string | null): number {
  if (str === null) {
    return 0;
  }
  return str.length;
}

Use Discriminated Unions for Type Narrowing

Discriminated unions are powerful for handling different shapes of data:

type Shape = 
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
  }
}

Leverage Utility Types

TypeScript provides several utility types that can help you manipulate types:

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type PartialTodo = Partial<Todo>;  // All properties are optional
type ReadonlyTodo = Readonly<Todo>;  // All properties are readonly
type TodoPreview = Pick<Todo, "title" | "completed">;  // Only specified properties
type TodoWithoutDescription = Omit<Todo, "description">;  // Exclude specified properties

Use
const
Assertions for Literal Types

Const assertions can be used to infer the most specific type:

const config = {
  endpoint: "https://api.example.com",
  timeout: 3000
} as const;

// Type of config:
// {
//   readonly endpoint: "https://api.example.com";
//   readonly timeout: 3000;
// }

Leverage Mapped Types for Dynamic Object Shapes

Mapped types allow you to create new types based on existing ones:

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

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

type NullableUser = Nullable<User>;
// Equivalent to:
// {
//   id: number | null;
//   name: string | null;
//   email: string | null;
// }

Use Intersection Types for Combining Types

Intersection types allow you to combine multiple types into one:

interface Loggable {
  log(message: string): void;
}

interface Serializable {
  serialize(): string;
}

type LoggableAndSerializable = Loggable & Serializable;

class MyClass implements LoggableAndSerializable {
  log(message: string) {
    console.log(message);
  }

  serialize() {
    return JSON.stringify(this);
  }
}

Leverage Type Inference

TypeScript's type inference is powerful. Use it to reduce verbosity while maintaining type safety:

// Instead of:
const numbers: number[] = [1, 2, 3, 4, 5];

// Let TypeScript infer the type:
const numbers = [1, 2, 3, 4, 5];

// For functions, you can often omit the return type:
function add(a: number, b: number) {
  return a + b;
}
// TypeScript infers the return type as number

Use Enums for a Set of Named Constants

Enums can make your code more readable and self-documenting:

enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}

function move(direction: Direction) {
  // ...
}

move(Direction.Up);

Implement the Repository Pattern

For larger applications, consider implementing the repository pattern to abstract data access:

interface UserRepository {
  findById(id: number): Promise<User>;
  save(user: User): Promise<void>;
  delete(id: number): Promise<void>;
}

class SqlUserRepository implements UserRepository {
  async findById(id: number): Promise<User> {
    // Implementation for SQL database
  }

  async save(user: User): Promise<void> {
    // Implementation for SQL database
  }

  async delete(id: number): Promise<void> {
    // Implementation for SQL database
  }
}

Conclusion

These TypeScript best practices will help you write cleaner, safer, and more maintainable code. Remember, TypeScript is a powerful tool, but it's up to you to use it effectively. By leveraging its type system, using interfaces and generics, and following these best practices, you can significantly improve your code quality and catch potential bugs before they make it to production.

As you continue to work with TypeScript, keep exploring its features and stay updated with the latest developments. The TypeScript team regularly introduces new features and improvements that can further enhance your development experience.

Happy coding, and may your types always be strong and your errors few!