Table of Contents

Share this post

TypeScript Best Practices for Large Projects
Frontendยทยท5 min readยท8,477 views

TypeScript Best Practices for Large Projects

Essential TypeScript patterns and practices for maintaining type safety in large codebases.

TypeScript Best Practices for Large Projects

TypeScript is powerful, but it's easy to write TypeScript that's hard to maintain. Here's how to keep your types manageable as your project grows.

Type vs Interface

Both work, but be consistent:

// Use interface for objects
interface User {
  id: number;
  name: string;
  email: string;
}

// Use type for unions, intersections, tuples
type Status = 'pending' | 'approved' | 'rejected';
type Point = [number, number];
type UserWithProfile = User & { profile: Profile };

Strict Configuration

Always enable strict mode:

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

Utility Types

Use built-in utilities:

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

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

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

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

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

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

Generic Functions

Write reusable functions:

// Bad: Specific to one type
function getFirstUser(array: User[]): User | undefined {
  return array[0];
}

// Good: Works with any type
function getFirst<T>(array: T[]): T | undefined {
  return array[0];
}

// Usage
const firstUser = getFirst(users);
const firstPost = getFirst(posts);

Discriminated Unions

Type-safe state management:

type ApiResponse<T> =
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: T };

function handleResponse<T>(response: ApiResponse<T>) {
  switch (response.status) {
    case 'loading':
      return 'Loading...';
    case 'error':
      return `Error: ${response.error}`;
    case 'success':
      return response.data; // TypeScript knows data exists
  }
}

Type Guards

Runtime type checking:

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'name' in value &&
    'email' in value
  );
}

// Usage
if (isUser(data)) {
  console.log(data.name); // TypeScript knows it's a User
}

Avoid Any

// Bad
function process(data: any) {
  return data.something;
}

// Better: Use unknown
function process(data: unknown) {
  if (isValidData(data)) {
    return data.something;
  }
}

// Best: Use generic
function process<T extends { something: string }>(data: T) {
  return data.something;
}

Const Assertions

Create readonly types:

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

// With const
const colors = ['red', 'green', 'blue'] as const;
// Type: readonly ['red', 'green', 'blue']

type Color = typeof colors[number];
// Type: 'red' | 'green' | 'blue'

Branded Types

Prevent mixing similar types:

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

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

function getUser(userId: UserId) {
  // ...
}

const userId = getUserId('123');
const postId = '456' as PostId;

getUser(userId); // โœ… OK
getUser(postId); // โŒ Error: PostId is not assignable to UserId

Template Literal Types

Type-safe string patterns:

type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Route = `/api/${string}`;
type APIEndpoint = `${HTTPMethod} ${Route}`;

// Valid
const endpoint1: APIEndpoint = 'GET /api/users';
const endpoint2: APIEndpoint = 'POST /api/posts';

// Invalid
const endpoint3: APIEndpoint = 'GET /users'; // Error

Conditional Types

Dynamic type logic:

type IsArray<T> = T extends any[] ? true : false;

type A = IsArray<string[]>; // true
type B = IsArray<string>; // false

// Practical example
type Flatten<T> = T extends Array<infer U> ? U : T;

type Str = Flatten<string[]>; // string
type Num = Flatten<number>; // number

Mapped Types

Transform existing types:

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

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

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

Function Overloads

Multiple function signatures:

function createElement(tag: 'div'): HTMLDivElement;
function createElement(tag: 'span'): HTMLSpanElement;
function createElement(tag: 'button'): HTMLButtonElement;
function createElement(tag: string): HTMLElement {
  return document.createElement(tag);
}

const div = createElement('div'); // Type: HTMLDivElement
const span = createElement('span'); // Type: HTMLSpanElement

Namespace Pattern

Organize related types:

namespace API {
  export interface Request {
    method: string;
    url: string;
  }
  
  export interface Response<T> {
    status: number;
    data: T;
  }
  
  export type Error = {
    message: string;
    code: number;
  };
}

// Usage
function handleRequest(req: API.Request): Promise<API.Response<User>> {
  // ...
}

Error Handling

Type-safe errors:

class AppError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number
  ) {
    super(message);
    this.name = 'AppError';
  }
}

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}

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

async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const user = await db.users.findOne(id);
    return { success: true, value: user };
  } catch (error) {
    return { 
      success: false, 
      error: error instanceof Error ? error : new Error('Unknown error')
    };
  }
}

Testing Types

Test your types:

// Type test utilities
type Equals<T, U> = 
  (<G>() => G extends T ? 1 : 2) extends
  (<G>() => G extends U ? 1 : 2)
    ? true
    : false;

// Usage
type Test1 = Equals<string, string>; // true
type Test2 = Equals<string, number>; // false

Common Pitfalls

1. Type Assertions

// Avoid
const user = data as User;

// Better
const user = isUser(data) ? data : null;

2. Non-null Assertions

// Avoid
const name = user!.name;

// Better
const name = user?.name ?? 'Unknown';

3. Index Signatures

// Avoid
interface Config {
  [key: string]: any;
}

// Better
interface Config {
  port: number;
  host: string;
  [key: string]: string | number;
}

Conclusion

Good TypeScript is about:

  • Leveraging the type system
  • Avoiding escape hatches (any, as)
  • Using utility types
  • Writing type-safe code
  • Making illegal states unrepresentable

Type safety is a journey, not a destination. Improve gradually.

Comments (0)

Loading comments...