TypeScript Best Practices for Modern Applications

Programming 10 min read
By Senior Developer

TypeScript Best Practices for Modern Applications

TypeScript has become the de facto standard for building large-scale JavaScript applications. Here are the essential practices that will help you write better, more maintainable TypeScript code.

1. Strict Type Configuration

Always start with strict TypeScript configuration:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitReturns": true,
    "noImplicitThis": true
  }
}

This catches potential issues early and enforces better coding practices.

2. Use Type Guards Effectively

Type guards help TypeScript understand your runtime checks:

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

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

3. Leverage Union Types and Discriminated Unions

Union types provide type safety for different possibilities:

type Status = 'loading' | 'success' | 'error';

interface LoadingState {
  status: 'loading';
}

interface SuccessState {
  status: 'success';
  data: any;
}

interface ErrorState {
  status: 'error';
  error: string;
}

type AppState = LoadingState | SuccessState | ErrorState;

4. Use Utility Types

TypeScript provides powerful utility types:

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

// Create a type without password
type PublicUser = Omit<User, 'password'>;

// Create a type with only id and name
type UserSummary = Pick<User, 'id' | 'name'>;

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

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

5. Generic Types for Reusability

Generics make your code reusable and type-safe:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: {
    page: number;
    limit: number;
    total: number;
  };
}

// Usage
type UserResponse = ApiResponse<User>;
type UsersResponse = PaginatedResponse<User>;

6. Proper Error Handling

Create typed error classes:

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

class ValidationError extends AppError {
  constructor(message: string, public field: string) {
    super(message, 'VALIDATION_ERROR', 400);
  }
}

// Usage with type guards
function handleError(error: unknown) {
  if (error instanceof ValidationError) {
    console.log(`Validation error on field: ${error.field}`);
  } else if (error instanceof AppError) {
    console.log(`App error: ${error.message} (${error.code})`);
  } else {
    console.log('Unknown error:', error);
  }
}

7. Environment Configuration

Type your environment variables:

interface EnvironmentConfig {
  NODE_ENV: 'development' | 'production' | 'test';
  API_URL: string;
  DATABASE_URL: string;
  JWT_SECRET: string;
  PORT: number;
}

function getEnvironmentConfig(): EnvironmentConfig {
  return {
    NODE_ENV: process.env.NODE_ENV as EnvironmentConfig['NODE_ENV'],
    API_URL: process.env.API_URL || 'http://localhost:3000',
    DATABASE_URL: process.env.DATABASE_URL || '',
    JWT_SECRET: process.env.JWT_SECRET || '',
    PORT: parseInt(process.env.PORT || '3000', 10),
  };
}

8. Use Const Assertions

Const assertions provide more specific types:

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

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

// Object const assertion
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
} as const;

9. Branded Types for Type Safety

Use branded types to prevent mixing similar primitive types:

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

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

function createProductId(id: string): ProductId {
  return id as ProductId;
}

// This prevents accidentally using wrong ID types
function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }

10. Performance Considerations

Use Type-Only Imports

import type { User } from './types';
import { validateUser } from './validation';

Prefer Interfaces Over Types for Objects

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

// Use type for unions, primitives, computed types
type Status = 'active' | 'inactive';
type UserWithStatus = User & { status: Status };

Testing with TypeScript

Write type-safe tests:

import { describe, it, expect } from 'vitest';
import type { User } from '../types';

describe('User validation', () => {
  it('should validate user correctly', () => {
    const mockUser: User = {
      id: 1,
      name: 'John Doe',
      email: 'john@example.com',
    };

    expect(validateUser(mockUser)).toBe(true);
  });
});

Key Takeaways

  1. Start strict - Enable all strict compiler options
  2. Type everything - Avoid any type whenever possible
  3. Use utility types - Leverage TypeScript’s built-in utility types
  4. Generic patterns - Make your code reusable with generics
  5. Error handling - Create typed error classes and use type guards
  6. Performance - Use type-only imports and prefer interfaces

TypeScript is more than just adding types to JavaScript - it’s about creating more reliable, maintainable applications through better design patterns and compile-time safety.


These practices have been tested in production applications serving millions of users. Start implementing them gradually in your projects for immediate benefits.