← Back to Blog

Mastering TypeScript: Best Practices for Large Codebases

Caleb BradshawFebruary 10, 2023
TypeScriptJavaScriptProgrammingType Safety
Mastering TypeScript: Best Practices for Large Codebases

Mastering TypeScript: Best Practices for Large Codebases

TypeScript has gained massive popularity in recent years, particularly for larger projects where its static typing system helps catch errors early and improves code maintainability. In this post, I'll share some advanced TypeScript patterns and best practices that I've found valuable when working on large-scale applications.

Why TypeScript Matters for Large Projects

Before diving into specific patterns, let's consider why TypeScript is particularly valuable for large codebases:

  1. Error Prevention: The static type system catches many errors during development rather than at runtime
  2. Better Refactoring: Types provide confidence when making large-scale changes
  3. Self-Documenting Code: Types serve as built-in documentation, making the code easier to understand
  4. Enhanced IDE Support: Intellisense, auto-completion, and inline documentation are significantly improved
  5. Team Collaboration: Interfaces act as contracts, making it easier for team members to work together

Essential TypeScript Patterns for Scalable Applications

1. Discriminated Unions

Discriminated unions (also known as tagged unions) are a powerful pattern for handling different types that share some common properties:

type Shape = 
  | { kind: 'circle'; radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'square'; size: number };

function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    case 'square':
      return shape.size ** 2;
  }
}

The kind property acts as a discriminator, allowing TypeScript to narrow down the type within each branch of the switch statement.

2. Type Guards and Type Predicates

Type guards allow you to refine types within conditional blocks:

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

function processValue(value: unknown) {
  if (isString(value)) {
    // TypeScript knows that value is a string here
    console.log(value.toUpperCase());
  }
}

The value is string syntax is a type predicate that tells TypeScript about the result of the type check.

3. Utility Types

TypeScript's built-in utility types allow you to transform types in various ways:

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

// Create a new type with only some properties
type UserBasicInfo = Pick<User, 'id' | 'name' | 'email'>;

// Create a type with all properties optional
type PartialUser = Partial<User>;

// Create a read-only version of the type
type ReadonlyUser = Readonly<User>;

// Extract the return type of a function
function createUser(): User {
  // Implementation...
  return {} as User;
}
type CreatedUser = ReturnType<typeof createUser>;

4. Generic Constraints

Generic constraints allow you to restrict the types that can be used with your generic functions or classes:

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(value: T): T {
  console.log(value.length);
  return value;
}

// Works with strings
logLength("Hello"); 

// Works with arrays
logLength([1, 2, 3]); 

// Works with any object that has a length property
logLength({ length: 10, name: "Something" });

// Error: Number doesn't have a length property
// logLength(42);

5. Module Augmentation

Sometimes you need to extend existing types from libraries. Module augmentation allows you to add properties to existing types:

// Extending Express Request object
import express from 'express';

declare global {
  namespace Express {
    interface Request {
      user?: {
        id: string;
        name: string;
        roles: string[];
      };
    }
  }
}

const app = express();
app.use((req, res, next) => {
  // TypeScript now knows about req.user
  if (req.user && req.user.roles.includes('admin')) {
    next();
  } else {
    res.status(403).send('Unauthorized');
  }
});

Project Organization Patterns

Beyond language features, the way you organize your TypeScript project is crucial for maintainability.

1. Feature-Based Organization

Instead of organizing by technical role (controllers, services, models), organize by feature or domain:

src/
  features/
    user/
      user.model.ts
      user.service.ts
      user.controller.ts
      user.test.ts
    product/
      product.model.ts
      product.service.ts
      product.controller.ts
      product.test.ts
  shared/
    utils/
    components/
    hooks/
  types/
    index.ts

This approach makes it easier to find related code and encourages better separation of concerns.

2. Barrel Files

Use barrel files (index.ts) to simplify imports:

// src/features/user/index.ts
export * from './user.model';
export * from './user.service';
export * from './user.controller';

// Usage elsewhere
import { User, createUser, updateUser } from '../features/user';

3. Path Aliases

Configure path aliases in tsconfig.json to avoid messy relative imports:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@features/*": ["src/features/*"],
      "@shared/*": ["src/shared/*"],
      "@types/*": ["src/types/*"]
    }
  }
}

With this configuration, you can import modules using these aliases:

import { User } from '@features/user';
import { formatDate } from '@shared/utils/date';

Advanced TypeScript Configuration

1. Strict Mode

Always enable strict mode in your tsconfig.json:

{
  "compilerOptions": {
    "strict": true
  }
}

This enables a range of type checking features including:

  • strictNullChecks: Makes handling of null and undefined more explicit
  • strictFunctionTypes: More accurate function parameter type checking
  • strictPropertyInitialization: Ensures class properties are initialized
  • noImplicitAny: Prevents variables from having an implicit 'any' type

2. ESLint Integration

Use ESLint with TypeScript-specific rules to enforce consistent code style and catch additional issues:

npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin

Then configure .eslintrc.js:

module.exports = {
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint'],
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
  ],
  rules: {
    // Custom rules here
  }
};

Conclusion

TypeScript provides powerful tools for building and maintaining large-scale applications. By leveraging these advanced patterns and best practices, you can create codebases that are not only type-safe but also more maintainable and easier to understand.

Remember that the goal of using TypeScript isn't just to avoid errors—it's to create a better development experience and produce code that's easier to reason about, refactor, and extend over time.

Have you found other TypeScript patterns particularly helpful in large projects? I'd love to hear about your experiences in the comments!