Why TypeScript?

TypeScript has become the de facto standard for building large-scale JavaScript applications. It adds static typing to JavaScript, catching errors at compile time rather than runtime, and providing excellent IDE support with autocomplete and refactoring tools.

Essential TypeScript Practices

1. Use Strict Mode

Always enable strict mode in your tsconfig.json:

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

This catches many potential bugs and enforces better type safety.

2. Avoid any Type

The any type defeats the purpose of TypeScript. Instead:

// Bad
function processData(data: any) {
  return data.value;
}

// Good
interface Data {
  value: string;
}

function processData(data: Data) {
  return data.value;
}

// Better - for truly unknown types
function processData(data: unknown) {
  if (typeof data === 'object' && data !== null && 'value' in data) {
    return (data as Data).value;
  }
  throw new Error('Invalid data');
}

3. Leverage Type Inference

TypeScript’s type inference is powerful—use it:

// Don't need to specify types here
const numbers = [1, 2, 3]; // TypeScript infers: number[]
const user = { name: 'John', age: 30 }; // inferred type

// Do specify when inference isn't clear
const config: Config = loadConfig();

Advanced Type Patterns

Utility Types

TypeScript provides built-in utility types:

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

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

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

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

// Readonly - make immutable
type ImmutableUser = Readonly<User>;

// Record - create object type
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>;

Discriminated Unions

Perfect for handling different states:

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

type SuccessState = {
  status: 'success';
  data: User[];
};

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

type ApiState = LoadingState | SuccessState | ErrorState;

function handleState(state: ApiState) {
  switch (state.status) {
    case 'loading':
      return 'Loading...';
    case 'success':
      return state.data; // TypeScript knows data exists
    case 'error':
      return state.error.message; // TypeScript knows error exists
  }
}

Generic Constraints

Make generics more useful:

// Without constraints
function getProperty<T>(obj: T, key: string) {
  return obj[key]; // Error: Element implicitly has 'any' type
}

// With constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]; // Type-safe!
}

const user = { name: 'John', age: 30 };
const name = getProperty(user, 'name'); // TypeScript knows it's a string

Structuring Types

Interface vs Type

Both work, but have different use cases:

// Interfaces - better for objects, can be extended
interface User {
  name: string;
  email: string;
}

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

// Types - better for unions, intersections, primitives
type ID = string | number;
type ApiResponse<T> = { data: T } | { error: string };

// Use interfaces for public APIs
// Use types for complex type manipulations

Organize with Namespaces

For large type definitions:

namespace API {
  export interface User {
    id: string;
    name: string;
  }

  export interface Post {
    id: string;
    title: string;
    authorId: User['id'];
  }

  export type Response<T> = {
    success: true;
    data: T;
  } | {
    success: false;
    error: string;
  };
}

// Usage
const user: API.User = { id: '1', name: 'John' };
const response: API.Response<API.User> = { success: true, data: user };

Type Guards

Create runtime type checking:

// Type predicate
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'name' in obj &&
    'email' in obj
  );
}

// Usage
function processData(data: unknown) {
  if (isUser(data)) {
    console.log(data.name); // TypeScript knows it's a User
  }
}

// Built-in type guards
typeof value === 'string'
value instanceof Date
Array.isArray(value)
'property' in object

Async/Await with Types

Handle promises correctly:

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

// Handle errors with proper types
async function safeFetchUser(id: string): Promise<User | null> {
  try {
    return await fetchUser(id);
  } catch (error) {
    if (error instanceof Error) {
      console.error(error.message);
    }
    return null;
  }
}

Configuration Best Practices

Optimize your tsconfig.json:

{
  "compilerOptions": {
    // Type Checking
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,

    // Modules
    "module": "ESNext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,

    // Emit
    "declaration": true,
    "sourceMap": true,
    "removeComments": false,

    // Interop
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,

    // Language
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  }
}

Testing with TypeScript

Type-safe tests:

import { describe, it, expect } from 'vitest';

describe('User Service', () => {
  it('should create a user', () => {
    const user: User = {
      id: '1',
      name: 'John',
      email: 'john@example.com',
      age: 30
    };

    expect(user.name).toBe('John');
    expect(user.email).toContain('@');
  });

  it('should validate user data', () => {
    const invalidUser = { name: 'John' }; // Missing required fields
    
    expect(() => {
      validateUser(invalidUser as User); // Type assertion needed
    }).toThrow();
  });
});

Common Pitfalls to Avoid

1. Type Assertions Overuse

// Bad - overriding type system
const user = data as User;

// Good - validate first
function isUser(data: unknown): data is User {
  // validation logic
}

const user = isUser(data) ? data : null;

2. Optional Chaining Misuse

// Risky - might hide bugs
const name = user?.profile?.name;

// Better - be explicit about optionality
type User = {
  profile?: {
    name: string;
  };
};

3. Ignoring Null/Undefined

// Bad
function getLength(str: string | null) {
  return str.length; // Error if null
}

// Good
function getLength(str: string | null) {
  return str?.length ?? 0;
}

Performance Tips

  1. Use const assertions for literal types:

    const routes = ['home', 'about', 'contact'] as const;
    type Route = typeof routes[number]; // 'home' | 'about' | 'contact'
    
  2. Lazy-load types for large applications:

    type LazyUser = () => Promise<typeof import('./user').User>;
    
  3. Use project references for monorepos:

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

Conclusion

TypeScript is more than just adding types to JavaScript—it’s about building more maintainable, self-documenting code. By following these practices:

  • Use strict mode and avoid any
  • Leverage TypeScript’s advanced features
  • Create proper type guards
  • Structure types logically
  • Configure your project correctly

You’ll create applications that are easier to maintain, refactor, and scale. The initial investment in learning TypeScript pays dividends as your codebase grows.

Start small, gradually adopt more advanced patterns, and let TypeScript help you catch bugs before they reach production!