TypeScript Best Practices: Writing Type-Safe, Maintainable Code

Master TypeScript with practical patterns for types, interfaces, generics, and error handling. Learn to write code that's both type-safe and readable.

Frontend Craft: TypeScript Best Practices: Writing Type-Safe, Maintainable Code

Why TypeScript?

TypeScript catches errors at compile time, provides better IDE support, and makes refactoring safer. After working on large codebases in both JavaScript and TypeScript, the difference is night and day.

When TypeScript shines:

  • Large codebases with multiple contributors
  • APIs with complex data structures
  • Applications that need to scale
  • Teams that value maintainability

When plain JavaScript might be enough:

  • Small scripts or prototypes
  • Projects with minimal logic
  • Solo projects where you control everything

Type Annotations: Be Explicit Where It Matters

Function Parameters and Return Types

// ✅ Good: Clear contract
function calculateTotal(price: number, quantity: number): number {
  return price * quantity;
}

// ❌ Bad: Implicit any
function calculateTotal(price, quantity) {
  return price * quantity;
}

When to Let TypeScript Infer

// ✅ Good: Inference works well
const total = calculateTotal(10, 5); // inferred as number
const items = [1, 2, 3]; // inferred as number[]

// ❌ Unnecessary: Over-annotation
const total: number = calculateTotal(10, 5);
const items: number[] = [1, 2, 3];

Rule of thumb: Annotate function signatures, let TypeScript infer variables.

Interfaces vs Types: Know the Difference

Use Interfaces for Objects

// ✅ Interfaces for object shapes
interface User {
  id: string;
  name: string;
  email: string;
}

interface AdminUser extends User {
  permissions: string[];
}

Use Types for Unions and Complex Types

// ✅ Types for unions
type Status = 'pending' | 'active' | 'inactive';
type Result = Success | Error;

// ✅ Types for computed properties
type ReadonlyUser = Readonly<User>;
type PartialUser = Partial<User>;

They’re Often Interchangeable

// Both work for object shapes
interface Point { x: number; y: number; }
type Point = { x: number; y: number };

// Key difference: Interfaces can be reopened
interface Window {
  customProperty: string;
}

// Types cannot be reopened
type Window = { /* can't add to this later */ };

My preference: Interfaces for public APIs, types for everything else.

Avoid the any Escape Hatch

When You’re Tempted to Use any

// ❌ Defeats the purpose of TypeScript
function processData(data: any) {
  return data.map((item: any) => item.value);
}

// ✅ Use unknown for truly unknown data
function processData(data: unknown) {
  if (Array.isArray(data)) {
    return data.map(item => {
      if (typeof item === 'object' && item !== null && 'value' in item) {
        return item.value;
      }
      return null;
    });
  }
  throw new Error('Invalid data');
}

// ✅ Or use generics
function processData<T extends { value: unknown }>(data: T[]) {
  return data.map(item => item.value);
}

The unknown Type is Your Friend

// unknown forces you to check types
function parseJSON(json: string): unknown {
  return JSON.parse(json);
}

const data = parseJSON('{"name": "John"}');

// ❌ Error: Object is of type 'unknown'
console.log(data.name);

// ✅ Type guard required
if (typeof data === 'object' && data !== null && 'name' in data) {
  console.log(data.name);
}

Generics: Write Reusable, Type-Safe Code

Basic Generic Function

// ✅ Generic preserves type information
function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}

const num = firstElement([1, 2, 3]); // number | undefined
const str = firstElement(['a', 'b']); // string | undefined

Generic Constraints

// Ensure T has a length property
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest('hello', 'hi'); // ✅ string
longest([1, 2], [1, 2, 3]); // ✅ number[]
longest(10, 20); // ❌ Error: number doesn't have length

Generic Utility Functions

// API response wrapper
type ApiResponse<T> = 
  | { success: true; data: T }
  | { success: false; error: string };

async function fetchUser(id: string): Promise<ApiResponse<User>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    const data = await response.json();
    return { success: true, data };
  } catch (error) {
    return { success: false, error: String(error) };
  }
}

// Usage with type narrowing
const result = await fetchUser('123');
if (result.success) {
  console.log(result.data.name); // ✅ data is User
} else {
  console.log(result.error); // ✅ error is string
}

Type Guards: Narrow Types Safely

Built-in Type Guards

function processValue(value: string | number) {
  if (typeof value === 'string') {
    return value.toUpperCase(); // ✅ value is string
  }
  return value.toFixed(2); // ✅ value is number
}

Custom Type Guards

interface Dog {
  bark(): void;
}

interface Cat {
  meow(): void;
}

// Type predicate
function isDog(animal: Dog | Cat): animal is Dog {
  return 'bark' in animal;
}

function makeSound(animal: Dog | Cat) {
  if (isDog(animal)) {
    animal.bark(); // ✅ TypeScript knows it's a Dog
  } else {
    animal.meow(); // ✅ TypeScript knows it's a Cat
  }
}

Discriminated Unions

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

function area(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;
  }
}

Utility Types: Don’t Reinvent the Wheel

Partial and Required

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

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

function updateUser(id: string, updates: UserUpdate) {
  // updates can have any subset of User properties
}

// Make all properties required
type CompleteUser = Required<UserUpdate>;

Pick and Omit

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

// Exclude specific properties
type UserWithoutEmail = Omit<User, 'email'>;

// Useful for API responses
type PublicUser = Omit<User, 'email' | 'password'>;

Record for Key-Value Maps

// Instead of index signature
type UserMap = Record<string, User>;

const users: UserMap = {
  'user1': { id: '1', name: 'John', email: 'john@example.com' },
  'user2': { id: '2', name: 'Jane', email: 'jane@example.com' },
};

// With specific keys
type Permissions = Record<'read' | 'write' | 'delete', boolean>;

const permissions: Permissions = {
  read: true,
  write: true,
  delete: false,
};

ReturnType and Parameters

function createUser(name: string, email: string) {
  return { id: crypto.randomUUID(), name, email };
}

// Extract return type
type User = ReturnType<typeof createUser>;

// Extract parameter types
type CreateUserParams = Parameters<typeof createUser>;
// [name: string, email: string]

Async TypeScript

Typing Promises

// ✅ Explicit Promise type
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// ✅ With error handling
async function fetchUserSafe(
  id: string
): Promise<User | null> {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) return null;
    return response.json();
  } catch {
    return null;
  }
}

Typing Event Handlers

// React example
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
  console.log(event.currentTarget.name);
}

function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
  console.log(event.target.value);
}

// Generic DOM
function handleDOMClick(event: MouseEvent) {
  if (event.target instanceof HTMLElement) {
    console.log(event.target.tagName);
  }
}

Strict Mode Configuration

Essential tsconfig.json Settings

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

Start strict, stay strict. It’s harder to add strict mode later.

Common Patterns

Builder Pattern with Types

class QueryBuilder<T> {
  private filters: Array<(item: T) => boolean> = [];

  where(predicate: (item: T) => boolean): this {
    this.filters.push(predicate);
    return this;
  }

  execute(items: T[]): T[] {
    return items.filter(item => 
      this.filters.every(filter => filter(item))
    );
  }
}

// Usage
const result = new QueryBuilder<User>()
  .where(u => u.age > 18)
  .where(u => u.email.includes('@'))
  .execute(users);

Branded Types for IDs

// Prevent ID confusion
type UserId = string & { readonly brand: unique symbol };
type PostId = string & { readonly brand: unique symbol };

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

function createPostId(id: string): PostId {
  return id as PostId;
}

function getUser(id: UserId): User { /* ... */ }
function getPost(id: PostId): Post { /* ... */ }

const userId = createUserId('123');
const postId = createPostId('456');

getUser(userId); // ✅
getUser(postId); // ❌ Error: Type mismatch

Const Assertions

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

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

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

Error Handling

Result Type Pattern

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

function divide(a: number, b: number): Result<number> {
  if (b === 0) {
    return { ok: false, error: new Error('Division by zero') };
  }
  return { ok: true, value: a / b };
}

// Usage
const result = divide(10, 2);
if (result.ok) {
  console.log(result.value); // ✅ value is number
} else {
  console.error(result.error); // ✅ error is Error
}

Type-Safe Error Classes

class ValidationError extends Error {
  constructor(
    message: string,
    public readonly field: string,
    public readonly code: string
  ) {
    super(message);
    this.name = 'ValidationError';
  }
}

function validateEmail(email: string): void {
  if (!email.includes('@')) {
    throw new ValidationError(
      'Invalid email format',
      'email',
      'INVALID_FORMAT'
    );
  }
}

// Usage
try {
  validateEmail('invalid');
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(`${error.field}: ${error.message}`);
  }
}

Performance Tips

Avoid Expensive Type Computations

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

// ✅ Better: Limit recursion or use built-in types
type ShallowPartial<T> = Partial<T>;

Use Type Inference

// ❌ Explicit generic often unnecessary
const items = new Array<number>();

// ✅ Let TypeScript infer
const items: number[] = [];

Testing with TypeScript

Type-Safe Mocks

// Mock factory
function createMockUser(overrides?: Partial<User>): User {
  return {
    id: '1',
    name: 'Test User',
    email: 'test@example.com',
    ...overrides,
  };
}

// Usage in tests
const user = createMockUser({ name: 'John' });
expect(user.name).toBe('John');

Type-Safe Test Utilities

function expectType<T>(value: T): void {}

// Compile-time type checks
expectType<number>(123); // ✅
expectType<number>('123'); // ❌ Compile error

Migration Strategy

Gradual Adoption

// tsconfig.json for gradual migration
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "strict": false,
    "noImplicitAny": false
  }
}

File-by-File Migration

  1. Rename .js to .ts
  2. Add // @ts-check to JavaScript files
  3. Fix immediate errors
  4. Enable stricter checks gradually
  5. Remove any types over time

Common Mistakes to Avoid

1. Type Assertions Gone Wild

// ❌ Dangerous: Bypasses type checking
const user = data as User;

// ✅ Better: Validate at runtime
function isUser(data: unknown): data is User {
  return (
    typeof data === 'object' &&
    data !== null &&
    'id' in data &&
    'name' in data &&
    'email' in data
  );
}

if (isUser(data)) {
  // data is User
}

2. Optional Chaining Overuse

// ❌ Hides real issues
user?.profile?.settings?.theme?.color;

// ✅ Better: Model data correctly
interface User {
  profile: {
    settings: {
      theme: { color: string };
    };
  };
}

3. Enum Misuse

// ❌ Enums have runtime cost
enum Status {
  Active,
  Inactive,
}

// ✅ Use union types
type Status = 'active' | 'inactive';

Best Practices Summary

  1. Enable strict mode from day one
  2. Avoid any, use unknown instead
  3. Let TypeScript infer where it can
  4. Use discriminated unions for complex types
  5. Prefer interfaces for object shapes
  6. Write custom type guards for runtime validation
  7. Use utility types instead of manual mapping
  8. Type function signatures, not every variable
  9. Keep types close to where they’re used
  10. Test types as part of your test suite

Next Steps

  • Learn advanced type manipulation (mapped types, conditional types)
  • Explore template literal types for string validation
  • Study decorator types for metadata
  • Practice type-level programming
  • Read the TypeScript Handbook

Resources