Development

TypeScript Best Practices for Modern Web Development

·5 min read
TypeScript Best Practices for Modern Web Development

TypeScript Best Practices for Modern Web Development

After years of working with TypeScript in large-scale applications, I've learned that proper typing isn't just about catching errors—it's about creating maintainable, self-documenting code. Let's explore the best practices that will help you write better TypeScript code.

Type System Fundamentals

1. Advanced Type Definitions

Leverage TypeScript's type system effectively:

// Use discriminated unions for better type safety
type Success<T> = {
  status: "success";
  data: T;
};

type Error = {
  status: "error";
  error: string;
};

type ApiResponse<T> = Success<T> | Error;

// Function that handles both cases
function handleResponse<T>(response: ApiResponse<T>): T | null {
  if (response.status === "success") {
    return response.data;
  }
  console.error(response.error);
  return null;
}

2. Generic Constraints

Use constraints to create more flexible, type-safe functions:

interface HasId {
  id: string | number;
}

class Repository<T extends HasId> {
  private items: Map<string | number, T>;

  constructor() {
    this.items = new Map();
  }

  add(item: T): void {
    this.items.set(item.id, item);
  }

  get(id: string | number): T | undefined {
    return this.items.get(id);
  }

  update(item: T): boolean {
    if (!this.items.has(item.id)) return false;
    this.items.set(item.id, item);
    return true;
  }
}

Type Safety Best Practices

1. Strict Type Checking

Enable strict mode and handle all cases:

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true
  }
}

// Example of strict null checks
function getUser(id: string): User | null {
  const user = database.find(id);
  if (!user) return null;
  return user;
}

// Usage with null checking
const user = getUser('123');
if (user) {
  // TypeScript knows user is not null here
  console.log(user.name);
}

2. Type Guards

Implement custom type guards for better type narrowing:

interface Admin {
  role: "admin";
  adminPrivileges: string[];
}

interface User {
  role: "user";
  userPreferences: Record<string, unknown>;
}

type SystemUser = Admin | User;

// Custom type guard
function isAdmin(user: SystemUser): user is Admin {
  return user.role === "admin";
}

function handleUser(user: SystemUser) {
  if (isAdmin(user)) {
    // TypeScript knows user is Admin here
    console.log(user.adminPrivileges);
  } else {
    // TypeScript knows user is User here
    console.log(user.userPreferences);
  }
}

Advanced Patterns

1. Builder Pattern with Types

Implement type-safe builder pattern:

class QueryBuilder<T> {
  private query: Partial<T> = {};

  where<K extends keyof T>(key: K, value: T[K]): QueryBuilder<T> {
    this.query[key] = value;
    return this;
  }

  build(): Partial<T> {
    return this.query;
  }
}

// Usage
interface User {
  name: string;
  age: number;
  email: string;
}

const query = new QueryBuilder<User>()
  .where("age", 25)
  .where("name", "John")
  .build();

2. Factory Pattern with Type Safety

Create type-safe factory functions:

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

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[Console]: ${message}`);
  }
}

class FileLogger implements Logger {
  log(message: string): void {
    // Log to file
    console.log(`[File]: ${message}`);
  }
}

type LoggerType = "console" | "file";

const createLogger = (type: LoggerType): Logger => {
  switch (type) {
    case "console":
      return new ConsoleLogger();
    case "file":
      return new FileLogger();
    default:
      throw new Error(`Unknown logger type: ${type}`);
  }
};

Error Handling

1. Custom Error Types

Create specific error types:

class ApplicationError extends Error {
  constructor(message: string, public code: string, public status: number) {
    super(message);
    this.name = "ApplicationError";
  }
}

class ValidationError extends ApplicationError {
  constructor(message: string) {
    super(message, "VALIDATION_ERROR", 400);
    this.name = "ValidationError";
  }
}

// Usage with type checking
try {
  throw new ValidationError("Invalid input");
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(error.code); // VALIDATION_ERROR
  }
}

2. Result Type Pattern

Implement the Result type pattern:

type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

async function fetchUser(id: string): Promise<Result<User, Error>> {
  try {
    const user = await database.users.find(id);
    if (!user) {
      return {
        ok: false,
        error: new Error(`User not found: ${id}`),
      };
    }
    return { ok: true, value: user };
  } catch (error) {
    return {
      ok: false,
      error: error instanceof Error ? error : new Error("Unknown error"),
    };
  }
}

// Usage
const result = await fetchUser("123");
if (result.ok) {
  // TypeScript knows result.value is User
  console.log(result.value.name);
} else {
  // TypeScript knows result.error is Error
  console.error(result.error.message);
}

Utility Types

1. Custom Utility Types

Create reusable utility types:

// Make all properties optional recursively
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

// Make all properties required recursively
type DeepRequired<T> = {
  [P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P];
};

// Extract properties of specific type
type PropertiesOfType<T, U> = {
  [P in keyof T]: T[P] extends U ? P : never;
}[keyof T];

// Usage
interface Config {
  api: {
    url: string;
    port: number;
  };
  debug: boolean;
}

type PartialConfig = DeepPartial<Config>;
type StringKeys = PropertiesOfType<Config["api"], string>; // 'url'

Best Practices

  1. Enable Strict Mode: Always use TypeScript's strict mode
  2. Use Type Inference: Let TypeScript infer types when possible
  3. Avoid Type Assertions: Use type guards instead of type assertions
  4. Document Complex Types: Add JSDoc comments for complex types
  5. Use Branded Types: For type-safe identifiers

Implementation Checklist

  1. Configure strict TypeScript settings
  2. Set up proper type definitions
  3. Implement type guards
  4. Use utility types effectively
  5. Add error handling
  6. Document complex types
  7. Set up linting rules
  8. Regular type safety audits

Conclusion

TypeScript's type system is powerful, but it requires careful consideration and proper practices to be effective. By following these patterns and practices, you can write more maintainable and type-safe code.

Resources