TypeScript Best Practices for Modern Web Development

TypeScript Best Practices for Modern Web Development

TypeScript Best Practices for Modern Web Development

TypeScript has become an essential tool in modern web development, offering static typing, enhanced IDE support, and improved code quality. However, simply using TypeScript doesn't automatically make your code better. This guide explores best practices for leveraging TypeScript effectively in your web projects, from basic typing to advanced patterns.

Why TypeScript Matters

Before diving into best practices, let's understand why TypeScript has become so popular:

  1. Type Safety: Catches type-related errors at compile time rather than runtime
  2. Developer Experience: Provides better autocompletion, navigation, and refactoring tools
  3. Documentation: Types serve as living documentation for your codebase
  4. Scalability: Makes large codebases more maintainable and easier to understand
  5. Ecosystem Support: Excellent integration with popular frameworks and libraries

Setting Up TypeScript Properly

Configuring tsconfig.json

A well-configured tsconfig.json is the foundation of any TypeScript project. Here's a recommended configuration with explanations:

{
  "compilerOptions": {
    "target": "ES2020",          /* Modern JavaScript features */
    "module": "ESNext",          /* Use ES modules */
    "moduleResolution": "node",  /* How to resolve modules */
    "esModuleInterop": true,     /* Better interop with CommonJS modules */
    "isolatedModules": true,     /* Ensure each file can be safely transpiled */
    "strict": true,              /* Enable all strict type checking options */
    "noImplicitAny": true,       /* Raise error on expressions with implied 'any' type */
    "strictNullChecks": true,    /* Enable strict null checks */
    "noUncheckedIndexedAccess": true, /* Add undefined to indexed access results */
    "forceConsistentCasingInFileNames": true, /* Ensure consistent casing in imports */
    "skipLibCheck": true,        /* Skip type checking of declaration files */
    "jsx": "react-jsx",          /* For React projects */
    "lib": ["DOM", "DOM.Iterable", "ESNext"], /* Standard libraries */
    "allowJs": true,             /* Allow JavaScript files to be compiled */
    "resolveJsonModule": true,   /* Allow importing JSON modules */
    "incremental": true,         /* Enable incremental compilation */
    "baseUrl": ".",              /* Base directory for resolving non-relative module names */
    "paths": {                   /* Path mapping for module aliases */
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*"],       /* Files to include */
  "exclude": ["node_modules", "build", "dist"] /* Files to exclude */
}

Strict Mode

Always enable strict: true in your TypeScript configuration. This enables a suite of type checking options that help catch more errors:

  • noImplicitAny: Prevents variables from having an implied any type
  • strictNullChecks: Makes handling of null and undefined more explicit
  • strictFunctionTypes: Enables more accurate function parameter type checking
  • strictBindCallApply: Ensures correct typing of bind, call, and apply methods
  • strictPropertyInitialization: Ensures class properties are initialized

While strict mode might seem challenging at first, it significantly improves code quality and prevents many common bugs.

Type Definitions Best Practices

Use Specific Types

Avoid using any whenever possible. It defeats the purpose of TypeScript by opting out of type checking.

// Bad
function processData(data: any) {
  return data.length * 2; // No type safety
}

// Good
function processData(data: string | string[]) {
  if (typeof data === 'string') {
    return data.length * 2;
  }
  return data.reduce((sum, item) => sum + item.length, 0) * 2;
}

Type vs Interface

Both type and interface can define object shapes, but they have subtle differences:

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

// Type alias
type User = {
  id: number;
  name: string;
};

Guidelines for choosing between them:

  • Use interface for public API definitions, object shapes that might be extended, and when you want to take advantage of declaration merging
  • Use type for unions, intersections, mapped types, and when you need to create complex types that aren't just object shapes
// Interface can be extended
interface BaseUser {
  id: number;
  name: string;
}

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

// Interface can be merged
interface User {
  id: number;
  name: string;
}

interface User {
  email: string; // Adds to the existing interface
}

// Type is better for unions and complex types
type UserRole = 'admin' | 'editor' | 'viewer';

type Result<T> = {
  data: T;
  error: null;
} | {
  data: null;
  error: Error;
};

Use Union Types for Better Type Safety

Union types allow a value to be one of several types, providing better type safety than using more general types.

// Less specific
function getLength(value: any): number {
  return value.length;
}

// More specific with union
function getLength(value: string | any[]): number {
  return value.length;
}

// Even better with type guards
function getLength(value: string | any[]): number {
  if (typeof value === 'string') {
    return value.length;
  }
  return value.length;
}

Leverage Literal Types

Literal types allow you to specify exact values a variable can have, which is useful for function parameters, state machines, and configuration options.

// Using string literal types
type Direction = 'north' | 'south' | 'east' | 'west';

function move(direction: Direction, distance: number) {
  // Implementation
}

// This works
move('north', 10);

// This will cause a type error
// move('up', 10);

Use Readonly for Immutability

The readonly modifier and Readonly<T> utility type help enforce immutability in your code.

// For properties
interface User {
  readonly id: number;
  name: string;
}

const user: User = { id: 1, name: 'John' };
// user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property

// For arrays
const numbers: ReadonlyArray<number> = [1, 2, 3];
// numbers.push(4); // Error: Property 'push' does not exist on type 'readonly number[]'

// For objects
const config: Readonly<{debug: boolean; mode: string}> = {
  debug: true,
  mode: 'development'
};
// config.debug = false; // Error: Cannot assign to 'debug' because it is a read-only property

Advanced Type Patterns

Discriminated Unions

Discriminated unions are a powerful pattern for modeling data that can be of several different shapes, each with its own set of properties.

type Success = {
  status: 'success';
  data: any;
};

type Error = {
  status: 'error';
  error: string;
};

type Loading = {
  status: 'loading';
};

type ApiResponse = Success | Error | Loading;

function handleResponse(response: ApiResponse) {
  switch (response.status) {
    case 'success':
      // TypeScript knows response has data property
      console.log(response.data);
      break;
    case 'error':
      // TypeScript knows response has error property
      console.error(response.error);
      break;
    case 'loading':
      // TypeScript knows response only has status property
      console.log('Loading...');
      break;
  }
}

Utility Types

TypeScript provides several utility types that help transform existing types into new ones.

// Partial makes all properties optional
type User = {
  id: number;
  name: string;
  email: string;
};

function updateUser(id: number, updates: Partial<User>) {
  // Implementation
}

updateUser(1, { name: 'New Name' }); // Valid, don't need to provide all fields

// Pick selects a subset of properties
type UserCredentials = Pick<User, 'email' | 'id'>;

// Omit removes properties
type UserWithoutId = Omit<User, 'id'>;

// Record creates a type with specified properties
type UserRoles = Record<string, 'admin' | 'editor' | 'viewer'>;

// Required makes all properties required
type OptionalUser = {
  id?: number;
  name?: string;
};

type RequiredUser = Required<OptionalUser>; // { id: number; name: string; }

Mapped Types

Mapped types allow you to create new types based on existing ones by transforming properties.

// Make all properties nullable
type Nullable<T> = { [P in keyof T]: T[P] | null };

type User = {
  id: number;
  name: string;
};

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

// Make all properties optional and readonly
type ReadonlyOptional<T> = { readonly [P in keyof T]?: T[P] };

type ConfigOptions = {
  debug: boolean;
  mode: 'development' | 'production';
};

type PartialConfig = ReadonlyOptional<ConfigOptions>;
// { readonly debug?: boolean; readonly mode?: 'development' | 'production'; }

Template Literal Types

Template literal types allow you to create string types based on concatenating other string types.

type EventName = 'click' | 'focus' | 'blur';
type ElementType = 'button' | 'input' | 'form';

// Creates: 'button:click' | 'button:focus' | 'button:blur' | 'input:click' | etc.
type ElementEvent = `${ElementType}:${EventName}`;

function handleEvent(elementEvent: ElementEvent, callback: () => void) {
  const [element, event] = elementEvent.split(':');
  // Implementation
}

handleEvent('button:click', () => console.log('Button clicked'));
// handleEvent('div:click', () => {}); // Error: Argument of type '"div:click"' is not assignable to parameter of type 'ElementEvent'

Type Guards and Type Narrowing

Type guards help TypeScript understand the type of a variable within a certain scope, allowing for more precise type checking.

User-Defined Type Guards

interface Fish {
  swim(): void;
  scales: boolean;
}

interface Bird {
  fly(): void;
  wings: boolean;
}

type Pet = Fish | Bird;

// User-defined type guard
function isFish(pet: Pet): pet is Fish {
  return (pet as Fish).scales !== undefined;
}

function careForPet(pet: Pet) {
  if (isFish(pet)) {
    // TypeScript knows pet is Fish here
    pet.swim();
  } else {
    // TypeScript knows pet is Bird here
    pet.fly();
  }
}

Using instanceof and typeof

class ApiError extends Error {
  statusCode: number;
  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
  }
}

class ValidationError extends Error {
  field: string;
  constructor(message: string, field: string) {
    super(message);
    this.field = field;
  }
}

function handleError(error: Error) {
  if (error instanceof ApiError) {
    // TypeScript knows error is ApiError
    console.error(`API Error ${error.statusCode}: ${error.message}`);
  } else if (error instanceof ValidationError) {
    // TypeScript knows error is ValidationError
    console.error(`Validation Error in ${error.field}: ${error.message}`);
  } else {
    // TypeScript knows error is just Error
    console.error(`Generic Error: ${error.message}`);
  }
}

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

Exhaustiveness Checking

Exhaustiveness checking ensures you've handled all possible cases in a switch statement or conditional.

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

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.sideLength ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    default:
      // This ensures we've handled all cases
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

If you add a new shape type but forget to handle it in getArea, TypeScript will show an error because the new shape type won't be assignable to never.

Asynchronous TypeScript

Typing Promises

// Explicitly typing the Promise return type
async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error(`Failed to fetch user: ${response.statusText}`);
  }
  return response.json();
}

// Using with Promise.all
async function fetchUserAndPosts(userId: number): Promise<[User, Post[]]> {
  const [user, posts] = await Promise.all([
    fetchUser(userId),
    fetchPosts(userId)
  ]);
  return [user, posts];
}

Error Handling with Types

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

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

// Usage
async function getUserData(userId: number) {
  const result = await fetchData<User>(`/api/users/${userId}`);
  
  if (result.success) {
    // TypeScript knows result.data is User
    console.log(`User name: ${result.data.name}`);
  } else {
    // TypeScript knows result.error is Error
    console.error(`Failed to fetch user: ${result.error.message}`);
  }
}

TypeScript with React

Typing Component Props

// Using interface
interface ButtonProps {
  text: string;
  onClick: () => void;
  disabled?: boolean;
  variant?: 'primary' | 'secondary' | 'danger';
}

const Button: React.FC<ButtonProps> = ({ 
  text, 
  onClick, 
  disabled = false, 
  variant = 'primary' 
}) => {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant}`}
    >
      {text}
    </button>
  );
};

// Usage
<Button 
  text="Click me" 
  onClick={() => console.log('Clicked')} 
  variant="primary" 
/>

Typing Component State

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

interface UserState {
  user: User | null;
  loading: boolean;
  error: string | null;
}

const UserProfile: React.FC = () => {
  const [state, setState] = useState<UserState>({
    user: null,
    loading: true,
    error: null
  });
  
  useEffect(() => {
    const fetchUser = async () => {
      try {
        const response = await fetch('/api/user');
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }
        const user: User = await response.json();
        setState({ user, loading: false, error: null });
      } catch (error) {
        setState({ 
          user: null, 
          loading: false, 
          error: error instanceof Error ? error.message : 'Unknown error' 
        });
      }
    };
    
    fetchUser();
  }, []);
  
  if (state.loading) {
    return <div>Loading...</div>;
  }
  
  if (state.error) {
    return <div>Error: {state.error}</div>;
  }
  
  return state.user ? (
    <div>
      <h1>{state.user.name}</h1>
      <p>{state.user.email}</p>
    </div>
  ) : null;
};

Typing Event Handlers

const Form: React.FC = () => {
  const [value, setValue] = useState('');
  
  // Typing the event parameter
  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setValue(event.target.value);
  };
  
  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    console.log('Submitted:', value);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        type="text" 
        value={value} 
        onChange={handleChange} 
        placeholder="Type something" 
      />
      <button type="submit">Submit</button>
    </form>
  );
};

Typing Refs

const InputWithFocus: React.FC = () => {
  // Typing the ref
  const inputRef = useRef<HTMLInputElement>(null);
  
  const focusInput = () => {
    // Need to check if current exists
    if (inputRef.current) {
      inputRef.current.focus();
    }
  };
  
  return (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={focusInput}>Focus Input</button>
    </div>
  );
};

Typing Custom Hooks

// Custom hook with TypeScript
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  // Get from local storage then
  // parse stored json or return initialValue
  const readValue = (): T => {
    if (typeof window === 'undefined') {
      return initialValue;
    }
    
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  };
  
  // State to store our value
  const [storedValue, setStoredValue] = useState<T>(readValue);
  
  // Return a wrapped version of useState's setter function that
  // persists the new value to localStorage
  const setValue = (value: T) => {
    try {
      // Save state
      setStoredValue(value);
      // Save to local storage
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(value));
      }
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  };
  
  return [storedValue, setValue];
}

// Usage
function App() {
  const [name, setName] = useLocalStorage<string>('name', 'Bob');
  const [items, setItems] = useLocalStorage<string[]>('items', []);
  
  return (
    <div>
      <input
        type="text"
        placeholder="Enter your name"
        value={name}
        onChange={e => setName(e.target.value)}
      />
      {/* ... */}
    </div>
  );
}

TypeScript with Next.js

Typing API Routes

// pages/api/user/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';

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

interface ErrorResponse {
  message: string;
}

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<User | ErrorResponse>
) {
  const { id } = req.query;
  
  if (req.method === 'GET') {
    // Fetch user from database
    const user: User = { id: Number(id), name: 'John Doe', email: 'john@example.com' };
    res.status(200).json(user);
  } else {
    res.status(405).json({ message: 'Method not allowed' });
  }
}

Typing getServerSideProps and getStaticProps

// pages/users/[id].tsx
import { GetServerSideProps, NextPage } from 'next';

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

interface UserPageProps {
  user: User;
}

const UserPage: NextPage<UserPageProps> = ({ user }) => {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
};

export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
  const { id } = context.params as { id: string };
  
  try {
    const response = await fetch(`https://api.example.com/users/${id}`);
    
    if (!response.ok) {
      return {
        notFound: true
      };
    }
    
    const user: User = await response.json();
    
    return {
      props: {
        user
      }
    };
  } catch (error) {
    return {
      redirect: {
        destination: '/error',
        permanent: false
      }
    };
  }
};

export default UserPage;

Typing Dynamic Routes

// pages/posts/[...slug].tsx
import { GetStaticPaths, GetStaticProps, NextPage } from 'next';

interface PostParams {
  slug: string[];
}

interface PostPageProps {
  slug: string[];
  content: string;
}

const PostPage: NextPage<PostPageProps> = ({ slug, content }) => {
  return (
    <div>
      <h1>Post: {slug.join('/')}</h1>
      <div>{content}</div>
    </div>
  );
};

export const getStaticPaths: GetStaticPaths<PostParams> = async () => {
  return {
    paths: [
      { params: { slug: ['2023', '01', 'hello-world'] } },
      { params: { slug: ['2023', '02', 'typescript-tips'] } }
    ],
    fallback: 'blocking'
  };
};

export const getStaticProps: GetStaticProps<PostPageProps, PostParams> = async (context) => {
  const { slug } = context.params as PostParams;
  
  // Fetch post content based on slug
  const content = `This is the content for ${slug.join('/')}`;
  
  return {
    props: {
      slug,
      content
    },
    revalidate: 60 // ISR: regenerate page every 60 seconds if requested
  };
};

export default PostPage;

Testing with TypeScript

Typing Jest Tests

// user.ts
export interface User {
  id: number;
  name: string;
  email: string;
}

export function formatUserName(user: User): string {
  return user.name.toUpperCase();
}

// user.test.ts
import { User, formatUserName } from './user';

describe('User utilities', () => {
  test('formatUserName should uppercase the name', () => {
    const user: User = {
      id: 1,
      name: 'John Doe',
      email: 'john@example.com'
    };
    
    expect(formatUserName(user)).toBe('JOHN DOE');
  });
});

Mocking with TypeScript

// api.ts
export interface User {
  id: number;
  name: string;
  email: string;
}

export interface UserService {
  getUser(id: number): Promise<User>;
  createUser(user: Omit<User, 'id'>): Promise<User>;
}

// userService.test.ts
import { UserService, User } from './api';

// Create a mock implementation of UserService
const mockUserService: jest.Mocked<UserService> = {
  getUser: jest.fn(),
  createUser: jest.fn()
};

describe('UserService', () => {
  beforeEach(() => {
    jest.resetAllMocks();
  });
  
  test('getUser should return a user', async () => {
    const mockUser: User = {
      id: 1,
      name: 'John Doe',
      email: 'john@example.com'
    };
    
    mockUserService.getUser.mockResolvedValue(mockUser);
    
    const result = await mockUserService.getUser(1);
    
    expect(mockUserService.getUser).toHaveBeenCalledWith(1);
    expect(result).toEqual(mockUser);
  });
});

Performance Optimization

Type-Only Imports

Use type-only imports to avoid importing runtime code when you only need types.

// Without type-only imports
import { User } from './user';

// With type-only imports
import type { User } from './user';

function processUser(user: User) {
  // Implementation
}

const Assertions

Use as const to create readonly tuple types and literal types.

// Without const assertion
const config = {
  api: {
    url: 'https://api.example.com',
    version: 'v1'
  },
  features: ['auth', 'profiles', 'messaging']
};
// Type: { api: { url: string; version: string; }; features: string[]; }

// With const assertion
const config = {
  api: {
    url: 'https://api.example.com',
    version: 'v1'
  },
  features: ['auth', 'profiles', 'messaging']
} as const;
// Type: { readonly api: { readonly url: "https://api.example.com"; readonly version: "v1"; }; readonly features: readonly ["auth", "profiles", "messaging"]; }

// Now we can do this
type Feature = typeof config.features[number]; // "auth" | "profiles" | "messaging"

Optimizing Build Times

  1. Use Project References: For large projects, split your codebase into smaller projects with references between them.
// tsconfig.json
{
  "references": [
    { "path": "./packages/common" },
    { "path": "./packages/client" },
    { "path": "./packages/server" }
  ]
}
  1. Incremental Compilation: Enable incremental compilation to speed up subsequent builds.
// tsconfig.json
{
  "compilerOptions": {
    "incremental": true
  }
}
  1. Skip Type Checking of Declaration Files: Use skipLibCheck to avoid checking all declaration files.
// tsconfig.json
{
  "compilerOptions": {
    "skipLibCheck": true
  }
}

Common Pitfalls and How to Avoid Them

Type Assertions

Avoid using type assertions (as) unless absolutely necessary, as they bypass TypeScript's type checking.

// Bad: Using type assertion
const user = { } as User;

// Good: Initialize with all required properties
const user: User = {
  id: 1,
  name: 'John',
  email: 'john@example.com'
};

// If you must use assertion, consider a safer approach
function assertIsUser(obj: any): asserts obj is User {
  if (typeof obj !== 'object' || obj === null) {
    throw new Error('Not an object');
  }
  if (!('id' in obj) || typeof obj.id !== 'number') {
    throw new Error('Missing or invalid id');
  }
  // Check other properties...
}

const data = fetchDataFromAPI();
assertIsUser(data);
// Now TypeScript knows data is User

Non-Null Assertion Operator

Avoid using the non-null assertion operator (!) as it can lead to runtime errors.

// Bad: Using non-null assertion
function getLength(str: string | null) {
  return str!.length; // Can cause runtime error if str is null
}

// Good: Use proper null checking
function getLength(str: string | null) {
  if (str === null) {
    return 0;
  }
  return str.length;
}

// Or use nullish coalescing
function getLength(str: string | null) {
  return str?.length ?? 0;
}

Type Widening

Be aware of type widening, which can make your types less specific than intended.

// Type widens to string
let name = 'John';
// name = 42; // Error: Type 'number' is not assignable to type 'string'

// Type widens to number
let age = 30;
// age = 'old'; // Error: Type 'string' is not assignable to type 'number'

// But with let, the type can be any string or any number
name = 'Jane'; // OK
age = 31; // OK

// To be more specific, use const with as const
const role = 'admin' as const; // Type is 'admin', not string
// role = 'user'; // Error: Type '"user"' is not assignable to type '"admin"'

Forgetting to Check for Undefined in Optional Properties

interface User {
  name: string;
  address?: {
    street: string;
    city: string;
  };
}

// Bad: Not checking for undefined
function getCity(user: User) {
  return user.address.city; // Error: Object is possibly 'undefined'
}

// Good: Proper checking
function getCity(user: User) {
  return user.address?.city; // Using optional chaining
}

// Or with a default value
function getCity(user: User) {
  return user.address?.city ?? 'Unknown';
}

Conclusion

TypeScript is a powerful tool that can significantly improve the quality and maintainability of your web applications. By following these best practices, you can leverage TypeScript's type system to catch errors early, write self-documenting code, and create more robust applications.

Remember that TypeScript is designed to help you, not to get in your way. When you encounter challenges, look for type-safe solutions rather than bypassing the type system. With practice, you'll find that TypeScript becomes an invaluable part of your development workflow, helping you write better code with fewer bugs.

As you continue your TypeScript journey, keep exploring advanced type features and patterns. The TypeScript ecosystem is constantly evolving, with new features and improvements in each release. Stay up to date with the latest developments and continue refining your TypeScript skills to build better web applications.