TypeScript best practices

React Specialist
January 14, 2025
0 MIN READ
#javascript#expo#authentication#typescript#best

TypeScript Best Practices for Modern Development Teams

TypeScript has become an essential tool for JavaScript development, offering type safety, better tooling, and improved maintainability for large-scale applications. As teams adopt TypeScript, following best practices becomes crucial to maximize its benefits while avoiding common pitfalls. This guide covers key TypeScript best practices that will help your team write more robust, maintainable code.

1. Leverage TypeScript's Type System Effectively

Use Strict Mode

Always enable strict mode in your tsconfig.json. This activates several important type-checking features:

{
  "compilerOptions": {
    "strict": true,
    // Other options...
  }
}

Strict mode includes:

  • noImplicitAny: Prevents the any type from being inferred
  • strictNullChecks: Forces explicit null/undefined handling
  • strictFunctionTypes: More accurate function type checking
  • And several other important checks

Avoid any and Prefer Specific Types

While any might seem convenient, it defeats TypeScript's purpose. Instead:

  1. Use unknown for values of uncertain type that need type checking
  2. Use type guards to narrow types
  3. Create precise interfaces and types
// Bad function processData(data: any) { // ... } // Good function processData(data: unknown) { if (typeof data === 'string') { // Now safely use as string } }

Use Utility Types

TypeScript provides powerful utility types that can reduce boilerplate:

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

// Create a type with only the fields needed for display
type UserDisplay = Pick<User, 'id' | 'name'>;

// Create a type with all fields optional
type PartialUser = Partial<User>;

// Create a type that omits certain fields
type UserWithoutEmail = Omit<User, 'email'>;

2. Organize and Structure Your Codebase

Use Type Aliases vs. Interfaces

Understand when to use type vs interface:

  • Use interface for object shapes that may need extension
  • Use type for unions, tuples, or complex type compositions
// Interface is better for extendable object shapes
interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

// Type is better for unions
type Status = 'active' | 'inactive' | 'pending';

Modularize Type Definitions

Keep type definitions close to where they're used:

  • For component props, define types in the same file
  • For shared types, create a dedicated types directory
  • Avoid global type pollution by using modules

Use Declaration Merging Judiciously

While declaration merging can be powerful, overuse can make code harder to understand. Use it primarily for:

  • Extending third-party type definitions
  • Library authoring scenarios
  • Well-documented augmentation points

3. Advanced Patterns for Better Type Safety

Use Discriminated Unions

This pattern helps TypeScript understand which variant of an object you're working with:

type NetworkState = | { state: 'loading' } | { state: 'success'; response: string } | { state: 'error'; error: Error }; function handleState(state: NetworkState) { switch (state.state) { case 'loading': // TypeScript knows state has no other properties here return 'Loading...'; case 'success': return state.response; case 'error': return state.error.message; } }

Implement Proper Error Handling

TypeScript's type system can help ensure proper error handling:

type Result<T, E = Error> = | { success: true; value: T } | { success: false; error: E }; function safeParse(json: string): Result<unknown, SyntaxError> { try { return { success: true, value: JSON.parse(json) }; } catch (e) { return { success: false, error: e as SyntaxError }; } }

Use Const Assertions for Literal Types

When you need to preserve literal types:

// 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"]

4. Tooling and Project Configuration

Configure Path Aliases

Use path aliases to avoid relative path hell:

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@components/*": ["src/components/*"],
      "@utils/*": ["src/utils/*"]
    }
  }
}

Now you can import like:

import { Button } from '@components/ui/Button';

Use Project References for Monorepos

For larger projects, use project references to improve build times:

// tsconfig.json
{
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/ui" }
  ]
}

Integrate with ESLint

Use @typescript-eslint to catch TypeScript-specific issues:

module.exports = {
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint'],
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:@typescript-eslint/recommended-requiring-type-checking'
  ],
  // ...
};

Conclusion

Adopting these TypeScript best practices will significantly improve your code quality, maintainability, and developer experience. Remember that TypeScript is most powerful when you fully embrace its type system rather than fighting against it. Start with strict mode, use precise types, leverage advanced patterns like discriminated unions, and configure your tooling properly. As your team becomes more proficient with TypeScript, you'll find it becomes an invaluable tool for catching errors early, improving documentation through types, and enabling safer refactoring.

The key is to view TypeScript not just as JavaScript with types, but as a way to model your application's behavior and constraints at the type level. This mindset shift, combined with these practical techniques, will help you get the most value from TypeScript in your projects.

Share this article