FrontendNov 12, 202514 min read

TypeScript Best Practices: Writing Type-Safe Code That Scales

Level up your TypeScript skills with proven patterns

TypeScriptReactNode.js
TypeScript Best Practices: Writing Type-Safe Code That Scales

Key Takeaways

  • Advanced utility types and generics
  • Type inference optimization
  • Strict mode configuration
  • Common patterns and anti-patterns
  • Integration with React and Node.js

TypeScript has become the industry standard for building large-scale JavaScript applications. But there's a significant difference between using TypeScript and using it well. In this guide, I'll share the patterns and practices that have helped me build more maintainable, bug-free applications.

Code on screen
TypeScript catches errors at compile time, saving hours of debugging

Start with Strict Mode

The most important TypeScript configuration is enabling strict mode. This activates a suite of checks that catch common errors and enforce best practices. Don't start a new project without it.

json
// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitReturns": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "esModuleInterop": true
  }
}

Mastering Utility Types

TypeScript's built-in utility types are incredibly powerful. Understanding them eliminates the need for most custom type definitions and makes your code more readable.

typescript
// Essential utility types
interface User {
  id: string;
  name: string;
  email: string;
  password: string;
  createdAt: Date;
  updatedAt: Date;
}

// Partial - all properties optional
type UpdateUserDTO = Partial<User>;

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

// Omit - exclude specific properties
type PublicUser = Omit<User, 'password'>;

// Required - make all properties required
type RequiredUser = Required<Partial<User>>;

// Record - create object type with specific keys
type UserRoles = Record<'admin' | 'user' | 'guest', boolean>;

// Extract & Exclude - filter union types
type StringOrNumber = string | number | boolean;
type OnlyStrings = Extract<StringOrNumber, string>; // string
type NoStrings = Exclude<StringOrNumber, string>; // number | boolean

Generic Patterns

Generics are TypeScript's most powerful feature. They enable you to write reusable, type-safe code that works with any type while maintaining full type inference.

typescript
// Generic API response wrapper
interface ApiResponse<T> {
  data: T;
  success: boolean;
  message?: string;
  timestamp: Date;
}

// Generic fetch function
async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
  const response = await fetch(url);
  const data = await response.json();
  return {
    data,
    success: response.ok,
    timestamp: new Date()
  };
}

// Usage - TypeScript infers the type
const users = await fetchData<User[]>('/api/users');
// users.data is typed as User[]

// Generic with constraints
interface HasId {
  id: string | number;
}

function findById<T extends HasId>(items: T[], id: T['id']): T | undefined {
  return items.find(item => item.id === id);
}

// Works with any type that has an id property
const user = findById(users.data, '123'); // Fully typed!
Developer coding
Generics enable reusable, type-safe code patterns

Type Guards and Narrowing

Type guards help TypeScript understand your code's control flow. Use them to narrow types and avoid unsafe type assertions.

typescript
// Custom type guard
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'email' in obj
  );
}

// Usage
function processData(data: unknown) {
  if (isUser(data)) {
    // TypeScript knows data is User here
    console.log(data.email);
  }
}

// Discriminated unions - the best pattern for variants
type Result<T, E = Error> = 
  | { success: true; data: T }
  | { success: false; error: E };

function handleResult<T>(result: Result<T>) {
  if (result.success) {
    // TypeScript knows result.data exists
    return result.data;
  } else {
    // TypeScript knows result.error exists
    throw result.error;
  }
}

React with TypeScript

TypeScript and React are a perfect match. Here are the patterns that make React development type-safe and enjoyable.

typescript
// Component props with children
interface CardProps {
  title: string;
  className?: string;
  children: React.ReactNode;
}

// Generic component
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
}

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

// Usage
<List
  items={users}
  renderItem={(user) => <span>{user.name}</span>}
  keyExtractor={(user) => user.id}
/>

Common Anti-Patterns to Avoid

  • Don't use 'any' - use 'unknown' and type guards instead
  • Avoid type assertions (as Type) - narrow types with guards
  • Don't export types from .ts files - use .d.ts for shared types
  • Avoid optional chaining abuse (?.) - fix your types instead
  • Don't ignore TypeScript errors - they're trying to help you
40%Fewer bugs
2xFaster refactoring
100%Type coverage goal
0'any' types allowed
HR

Written by Hammas Rashid

Full-Stack Developer passionate about building scalable web applications and sharing knowledge with the developer community.

Chat on WhatsApp