LET'S WORK TOGETHER

TypeScript Best Practices for Modern JavaScript Development

10 min readBy Hamza
TypeScriptJavaScriptWeb DevelopmentType SafetyBest PracticesProgramming

TypeScript Best Practices for Modern JavaScript Development


TypeScript has become an essential tool for modern JavaScript development, providing static typing that helps catch errors early and improve code quality. Let's explore the best practices that will help you write better TypeScript code.


1. Strict Type Checking


Always enable strict type checking in your tsconfig.json:


{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

Benefits of Strict Mode


  • Better type safety: Catches potential runtime errors at compile time
  • Improved IntelliSense: Better autocompletion and error detection
  • Self-documenting code: Types serve as documentation

2. Interface vs Type Aliases


When to Use Interfaces


Use interfaces for object shapes and class implementations:


// Interface for object shapes
interface User {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
}

// Interface with method signatures
interface UserRepository {
  findById(id: number): Promise<User | null>;
  save(user: Omit<User, 'id' | 'createdAt'>): Promise<User>;
  delete(id: number): Promise<void>;
}

// Class implementing interface
class DatabaseUserRepository implements UserRepository {
  async findById(id: number): Promise<User | null> {
    // Implementation
  }

  async save(user: Omit<User, 'id' | 'createdAt'>): Promise<User> {
    // Implementation
  }

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

When to Use Type Aliases


Use type aliases for unions, intersections, and complex types:


// Union types
type Status = 'pending' | 'approved' | 'rejected';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

// Function types
type EventHandler<T> = (event: T) => void;
type Validator<T> = (value: unknown) => value is T;

// Complex types
type ApiResponse<T> = {
  data?: T;
  error?: string;
  status: number;
};

// Mapped types
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type UserCreateInput = Optional<User, 'id' | 'createdAt'>;

3. Generics for Reusable Code


Generic Functions


// Generic function that works with any type
function createArray<T>(length: number, value: T): T[] {
  return Array(length).fill(value);
}

// Generic constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

// Usage
const numbers = createArray(5, 0); // number[]
const strings = createArray(3, 'hello'); // string[]

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

Generic Classes


class Repository<T> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  findById(id: number): T | undefined {
    return this.items.find((item: any) => item.id === id);
  }

  findAll(): T[] {
    return [...this.items];
  }
}

interface Entity {
  id: number;
}

class UserRepository extends Repository<User> {
  findByName(name: string): User | undefined {
    return this.findAll().find(user => user.name === name);
  }
}

4. Utility Types


Built-in Utility Types


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

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

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

// Omit specific properties
type UserCreateInput = Omit<User, 'id' | 'createdAt'>;

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

// Create a type with null values
type NullableUser = { [K in keyof User]: User[K] | null };

Custom Utility Types


// Deep partial utility type
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

// Return value type extraction
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

// Async function return type
type AsyncReturnType<T extends (...args: any[]) => Promise<any>> =
  T extends (...args: any[]) => Promise<infer R> ? R : never;

// Usage
type CreateUserResponse = AsyncReturnType<typeof createUser>; // Promise<User>

5. Type Guards and Type Predicates


Custom Type Guards


interface Cat {
  type: 'cat';
  meow(): void;
}

interface Dog {
  type: 'dog';
  bark(): void;
}

type Animal = Cat | Dog;

// Type guard function
function isCat(animal: Animal): animal is Cat {
  return animal.type === 'cat';
}

function makeSound(animal: Animal): void {
  if (isCat(animal)) {
    animal.meow(); // TypeScript knows this is a Cat
  } else {
    animal.bark(); // TypeScript knows this is a Dog
  }
}

// Generic type guard
function hasProperty<T, K extends string>(
  obj: T,
  prop: K
): obj is T & Record<K, unknown> {
  return prop in obj;
}

instanceof and typeof Type Guards


function processValue(value: unknown): void {
  if (typeof value === 'string') {
    // TypeScript knows value is string here
    console.log(value.toUpperCase());
  } else if (typeof value === 'number') {
    // TypeScript knows value is number here
    console.log(value.toFixed(2));
  }

  if (value instanceof Date) {
    // TypeScript knows value is Date here
    console.log(value.toISOString());
  }
}

6. Error Handling Patterns


Discriminated Unions for Error Handling


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

async function safeFetch<T>(url: string): Promise<Result<T>> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      return {
        success: false,
        error: new Error(`HTTP error! status: ${response.status}`)
      };
    }
    const data = await response.json();
    return { success: true, data };
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error : new Error('Unknown error')
    };
  }
}

// Usage
async function getUser(id: number): Promise<User | null> {
  const result = await safeFetch<User>(`/api/users/${id}`);

  if (result.success) {
    return result.data;
  } else {
    console.error(result.error);
    return null;
  }
}

7. Configuration and Environment


Environment Variable Types


// env.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV: 'development' | 'production' | 'test';
      DATABASE_URL: string;
      API_KEY: string;
      PORT?: string;
    }
  }
}

// Config object with type safety
interface AppConfig {
  port: number;
  database: {
    url: string;
    ssl: boolean;
  };
  api: {
    key: string;
    timeout: number;
  };
}

function createConfig(): AppConfig {
  const port = parseInt(process.env.PORT || '3000', 10);
  const isDevelopment = process.env.NODE_ENV === 'development';

  return {
    port,
    database: {
      url: process.env.DATABASE_URL,
      ssl: !isDevelopment
    },
    api: {
      key: process.env.API_KEY,
      timeout: 5000
    }
  };
}

export const config = createConfig();

8. API Response Types


Strongly Typed API Responses


// API response types
interface ApiResponse<T = any> {
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
    details?: unknown;
  };
  meta?: {
    pagination?: {
      page: number;
      limit: number;
      total: number;
    };
  };
}

// Specific response types
interface UserResponse extends ApiResponse<User> {}
interface UsersResponse extends ApiResponse<User[]> {
  meta?: ApiResponse['meta'] & {
    pagination: NonNullable<ApiResponse['meta']>['pagination'];
  };
}

// Typed API client
class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  async get<T>(path: string): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`);
    return response.json() as Promise<T>;
  }

  async post<T>(path: string, body: unknown): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body)
    });
    return response.json() as Promise<T>;
  }
}

// Usage
const api = new ApiClient('/api');
const userResponse = await api.get<UserResponse>('/users/1');

9. Testing with TypeScript


Type-Safe Test Utilities


// test-utils.ts
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement, ReactNode } from 'react';

interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  wrapper?: ({ children }: { children: ReactNode }) => ReactElement;
}

function customRender(
  ui: ReactElement,
  options: CustomRenderOptions = {}
) {
  const { wrapper: Wrapper, ...rest } = options;

  if (!Wrapper) {
    return render(ui, rest);
  }

  return render(ui, { wrapper: Wrapper, ...rest });
}

// Mock type utilities
type MockFunction<T extends (...args: any[]) => any> = {
  (...args: Parameters<T>): ReturnType<T>;
  mock: {
    calls: Parameters<T>[];
    results: ReturnType<T>[];
  };
};

// Type-safe mocking
const mockFetch = jest.fn() as MockFunction<typeof fetch>;

10. Best Practices Summary


Do's


  • ✅ Use strict mode in tsconfig.json
  • ✅ Prefer interfaces for object shapes
  • ✅ Use type aliases for unions and complex types
  • ✅ Implement type guards for runtime type checking
  • ✅ Use generics for reusable code
  • ✅ Type your environment variables
  • ✅ Create specific types for API responses

Don'ts


  • ❌ Use any unless absolutely necessary
  • ❌ Disable strict mode rules
  • ❌ Ignore TypeScript errors
  • ❌ Use as without proper type checking
  • ❌ Create overly complex types without documentation

Conclusion


TypeScript provides powerful tools for building type-safe applications. By following these best practices, you can write more maintainable, readable, and reliable code. Remember that TypeScript is not just about catching errors—it's about better code organization, documentation, and developer experience.


Start implementing these practices gradually in your projects, and you'll see immediate improvements in your code quality and development workflow.