TypeScript Best Practices: Writing Type-Safe JavaScript Code

December 28, 2023 - 6 min read

TypeScript has revolutionized JavaScript development by adding static typing to the language. It helps catch errors at compile time, provides better IDE support, and makes code more maintainable.

Why TypeScript?

TypeScript offers several advantages over plain JavaScript:

  • Type Safety: Catch errors before runtime
  • Better IDE Support: Enhanced autocomplete and refactoring
  • Improved Documentation: Types serve as documentation
  • Easier Refactoring: Confident code changes
  • Better Team Collaboration: Clear interfaces and contracts

Basic Type Annotations

Start with the fundamentals of TypeScript types:

// Primitive types
let name: string = 'John';
let age: number = 30;
let isActive: boolean = true;
let hobbies: string[] = ['reading', 'coding'];
 
// Object types
interface User {
  id: number;
  name: string;
  email: string;
  age?: number; // Optional property
}
 
const user: User = {
  id: 1,
  name: 'John Doe',
  email: 'john@example.com'
};

Function Types

TypeScript provides powerful function typing capabilities:

// Function with typed parameters and return
function add(a: number, b: number): number {
  return a + b;
}
 
// Arrow function with types
const multiply = (a: number, b: number): number => a * b;
 
// Function with optional parameters
function greet(name: string, greeting?: string): string {
  return greeting ? `${greeting}, ${name}!` : `Hello, ${name}!`;
}
 
// Function with rest parameters
function sum(...numbers: number[]): number {
  return numbers.reduce((total, num) => total + num, 0);
}

Interfaces and Types

Use interfaces and types to define contracts:

// Interface for API response
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}
 
// Type alias for union types
type Status = 'loading' | 'success' | 'error';
 
// Interface extending another interface
interface Employee extends User {
  department: string;
  salary: number;
}
 
// Interface with methods
interface Calculator {
  add(a: number, b: number): number;
  subtract(a: number, b: number): number;
}

Generics

Generics provide type safety while maintaining flexibility:

// Generic function
function identity<T>(arg: T): T {
  return arg;
}
 
// Generic interface
interface Container<T> {
  value: T;
  getValue(): T;
}
 
// Generic class
class Stack<T> {
  private items: T[] = [];
 
  push(item: T): void {
    this.items.push(item);
  }
 
  pop(): T | undefined {
    return this.items.pop();
  }
 
  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }
}

Union and Intersection Types

Combine types for more flexible type definitions:

// Union types
type StringOrNumber = string | number;
type Status = 'idle' | 'loading' | 'success' | 'error';
 
// Intersection types
interface HasName {
  name: string;
}
 
interface HasAge {
  age: number;
}
 
type Person = HasName & HasAge;
 
// Type guards
function isString(value: StringOrNumber): value is string {
  return typeof value === 'string';
}
 
function processValue(value: StringOrNumber): string {
  if (isString(value)) {
    return value.toUpperCase();
  } else {
    return value.toString();
  }
}

Advanced Type Features

Mapped Types

// Make all properties optional
type Partial<T> = {
  [P in keyof T]?: T[P];
};
 
// Make all properties required
type Required<T> = {
  [P in keyof T]-?: T[P];
};
 
// Make all properties readonly
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};
 
// Example usage
interface User {
  id: number;
  name: string;
  email: string;
}
 
type PartialUser = Partial<User>;
type RequiredUser = Required<User>;
type ReadonlyUser = Readonly<User>;

Conditional Types

// Conditional type
type NonNullable<T> = T extends null | undefined ? never : T;
 
// Extract return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
 
// Extract parameter types
type Parameters<T> = T extends (...args: infer P) => any ? P : never;

Best Practices

1. Use Strict Mode

Enable strict mode in your tsconfig.json:

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

2. Prefer Interfaces for Object Shapes

// Good
interface User {
  id: number;
  name: string;
}
 
// Avoid
type User = {
  id: number;
  name: string;
};

3. Use Type Assertions Sparingly

// Good - when you know more than TypeScript
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
 
// Better - type guards
function isCanvas(element: HTMLElement): element is HTMLCanvasElement {
  return element.tagName === 'CANVAS';
}

4. Leverage Utility Types

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}
 
// Omit sensitive data
type PublicUser = Omit<User, 'password'>;
 
// Pick specific properties
type UserCredentials = Pick<User, 'email' | 'password'>;
 
// Make some properties optional
type UpdateUser = Partial<Pick<User, 'name' | 'email'>>;

5. Use Enums for Constants

enum UserRole {
  ADMIN = 'admin',
  USER = 'user',
  MODERATOR = 'moderator'
}
 
enum HttpStatus {
  OK = 200,
  CREATED = 201,
  BAD_REQUEST = 400,
  UNAUTHORIZED = 401
}

Error Handling

TypeScript helps with better error handling:

// Result type for error handling
type Result<T, E = Error> = 
  | { success: true; data: T }
  | { success: false; error: E };
 
// Async result
async function fetchUser(id: number): Promise<Result<User>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    const user = await response.json();
    return { success: true, data: user };
  } catch (error) {
    return { 
      success: false, 
      error: error instanceof Error ? error : new Error('Unknown error') 
    };
  }
}

React with TypeScript

TypeScript works excellently with React:

import React, { useState, useEffect } from 'react';
 
interface User {
  id: number;
  name: string;
  email: string;
}
 
interface UserListProps {
  users: User[];
  onUserSelect: (user: User) => void;
}
 
const UserList: React.FC<UserListProps> = ({ users, onUserSelect }) => {
  const [selectedUser, setSelectedUser] = useState<User | null>(null);
 
  const handleUserClick = (user: User) => {
    setSelectedUser(user);
    onUserSelect(user);
  };
 
  return (
    <ul>
      {users.map(user => (
        <li 
          key={user.id}
          onClick={() => handleUserClick(user)}
          className={selectedUser?.id === user.id ? 'selected' : ''}
        >
          {user.name}
        </li>
      ))}
    </ul>
  );
};

Testing with TypeScript

TypeScript improves testing experience:

import { describe, it, expect } from 'vitest';
 
interface Calculator {
  add(a: number, b: number): number;
  subtract(a: number, b: number): number;
}
 
class SimpleCalculator implements Calculator {
  add(a: number, b: number): number {
    return a + b;
  }
 
  subtract(a: number, b: number): number {
    return a - b;
  }
}
 
describe('Calculator', () => {
  let calculator: Calculator;
 
  beforeEach(() => {
    calculator = new SimpleCalculator();
  });
 
  it('should add two numbers correctly', () => {
    expect(calculator.add(2, 3)).toBe(5);
  });
 
  it('should subtract two numbers correctly', () => {
    expect(calculator.subtract(5, 3)).toBe(2);
  });
});

Performance Considerations

1. Avoid Excessive Generics

// Good - simple and clear
function map<T, U>(array: T[], fn: (item: T) => U): U[] {
  return array.map(fn);
}
 
// Avoid - overly complex
function complexGeneric<
  T extends object,
  K extends keyof T,
  V extends T[K],
  R extends Record<K, V>
>(obj: T, key: K, value: V): R {
  return { ...obj, [key]: value } as R;
}

2. Use const assertions

// Good - more specific types
const colors = ['red', 'green', 'blue'] as const;
type Color = typeof colors[number];
 
// Avoid - less specific
const colors = ['red', 'green', 'blue']; // string[]

Conclusion

TypeScript is a powerful tool that significantly improves JavaScript development. By following these best practices, you can write more maintainable, type-safe code that scales with your project.

Remember to:

  • Start with strict mode enabled
  • Use interfaces for object shapes
  • Leverage utility types
  • Write comprehensive type definitions
  • Use type guards for runtime type checking

TypeScript is an investment that pays off in the long run with better code quality and developer experience.