Skip to main content
Background Image

TypeScript Best Practices: Writing Maintainable Code

·1128 words·6 mins· loading · loading · ·

🎯 Why TypeScript?
#

TypeScript is a strongly typed superset of JavaScript that compiles to plain JavaScript. It provides static type checking, better IDE support, and helps catch errors at compile time rather than runtime.

✨ Key Benefits
#

  • Type Safety: Catch errors before they reach production
  • Better IDE Support: Enhanced autocomplete and refactoring
  • Self-Documenting Code: Types serve as documentation
  • Easier Refactoring: Confident code changes with type checking

🏗️ Type System Fundamentals
#

1. Basic Types
#

// Primitive types
let name: string = "John";
let age: number = 30;
let isActive: boolean = true;
let items: string[] = ["apple", "banana"];
let user: { name: string; age: number } = { name: "John", age: 30 };

// Union types
let id: string | number = "123";
let status: "pending" | "approved" | "rejected" = "pending";

// Optional properties
interface User {
  name: string;
  age?: number; // Optional
  email: string;
}

2. Interfaces vs Type Aliases
#

// Use interfaces for object shapes
interface User {
  id: number;
  name: string;
  email: string;
  isActive?: boolean;
}

// Use type aliases for unions and complex types
type Status = "pending" | "approved" | "rejected";
type UserWithRole = User & { role: "admin" | "user" };
type EventHandler = (event: Event) => void;

🔧 Advanced Type Features
#

1. Generics
#

// Generic function
function identity<T>(arg: T): T {
  return arg;
}

// Generic interface
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

// Generic constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

// Usage
const user = { name: "John", age: 30 };
const name = getProperty(user, "name"); // string
const age = getProperty(user, "age"); // number

2. Utility Types
#

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

// Built-in utility types
type PartialUser = Partial<User>; // All properties optional
type RequiredUser = Required<User>; // All properties required
type UserName = Pick<User, "name" | "email">; // Pick specific properties
type UserWithoutId = Omit<User, "id">; // Exclude specific properties
type ReadonlyUser = Readonly<User>; // All properties readonly

// Custom utility types
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

🛡️ Type Safety Patterns
#

1. Type Guards
#

// Type predicate
function isString(value: unknown): value is string {
  return typeof value === "string";
}

function isUser(obj: any): obj is User {
  return obj && typeof obj.name === "string" && typeof obj.email === "string";
}

// Usage
function processValue(value: unknown) {
  if (isString(value)) {
    // TypeScript knows value is string here
    console.log(value.toUpperCase());
  }
}

2. Discriminated Unions
#

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

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

type ErrorState = {
  status: "error";
  error: string;
};

type ApiState = LoadingState | SuccessState | ErrorState;

function handleApiState(state: ApiState) {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      return `Loaded ${state.data.length} users`;
    case "error":
      return `Error: ${state.error}`;
  }
}

🎨 Code Organization
#

1. Module Structure
#

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

export type CreateUserRequest = Omit<User, "id">;
export type UpdateUserRequest = Partial<CreateUserRequest>;

// services/userService.ts
import { User, CreateUserRequest, UpdateUserRequest } from "../types/user";

export class UserService {
  async getUsers(): Promise<User[]> {
    // Implementation
  }

  async createUser(user: CreateUserRequest): Promise<User> {
    // Implementation
  }

  async updateUser(id: number, user: UpdateUserRequest): Promise<User> {
    // Implementation
  }
}

2. Error Handling
#

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

async function fetchUser(id: number): Promise<Result<User>> {
  try {
    const user = await api.getUser(id);
    return { success: true, data: user };
  } catch (error) {
    return { success: false, error: error as Error };
  }
}

// Usage
const result = await fetchUser(1);
if (result.success) {
  console.log(result.data.name);
} else {
  console.error(result.error.message);
}

🚀 Performance Considerations
#

1. Type Inference
#

// Let TypeScript infer types when possible
const users = ["John", "Jane", "Bob"]; // string[]
const user = { name: "John", age: 30 }; // { name: string; age: number }

// Only add explicit types when necessary
const apiUrl: string = process.env.API_URL || "http://localhost:3000";

2. Conditional Types
#

type NonNullable<T> = T extends null | undefined ? never : T;
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

// Advanced conditional types
type ApiEndpoint<T> = T extends "users" 
  ? User[] 
  : T extends "posts" 
  ? Post[] 
  : never;

function fetchData<T extends "users" | "posts">(endpoint: T): Promise<ApiEndpoint<T>> {
  // Implementation
}

📝 Best Practices
#

1. Configuration
#

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true
  }
}

2. Code Guidelines
#

  1. Use strict mode - Enable all strict type checking options
  2. Prefer interfaces over types - For object shapes, use interfaces
  3. Use meaningful names - UserRepository instead of Repo
  4. Avoid any - Use unknown or specific types instead
  5. Use enums for constants - enum Status { Pending, Approved, Rejected }
  6. Document complex types - Add JSDoc comments for complex type definitions

3. Testing with Types
#

// Type testing
type Assert<T extends true> = T;
type IsString<T> = T extends string ? true : false;

// These will cause compile errors if types don't match
type Test1 = Assert<IsString<"hello">>; // ✅
type Test2 = Assert<IsString<123>>; // ❌ Compile error

// Runtime type checking
function assertIsUser(obj: any): asserts obj is User {
  if (!obj || typeof obj.name !== "string" || typeof obj.email !== "string") {
    throw new Error("Invalid user object");
  }
}

🎯 Common Patterns
#

1. Builder Pattern
#

class QueryBuilder<T> {
  private query: Partial<T> = {};

  where<K extends keyof T>(key: K, value: T[K]): this {
    this.query[key] = value;
    return this;
  }

  build(): Partial<T> {
    return { ...this.query };
  }
}

// Usage
const userQuery = new QueryBuilder<User>()
  .where("name", "John")
  .where("isActive", true)
  .build();

2. Factory Pattern
#

type UserRole = "admin" | "user" | "guest";

interface UserFactory {
  createUser(role: UserRole): User;
}

class ConcreteUserFactory implements UserFactory {
  createUser(role: UserRole): User {
    const baseUser = { id: Date.now(), email: "" };
    
    switch (role) {
      case "admin":
        return { ...baseUser, name: "Admin", permissions: ["read", "write", "delete"] };
      case "user":
        return { ...baseUser, name: "User", permissions: ["read"] };
      case "guest":
        return { ...baseUser, name: "Guest", permissions: [] };
    }
  }
}

🎉 Conclusion
#

TypeScript is a powerful tool that can significantly improve your development experience and code quality. By following these best practices and patterns, you’ll write more maintainable, scalable, and error-free applications.

Remember to:

  • Start with strict mode enabled
  • Use types to document your code
  • Leverage TypeScript’s advanced features like generics and utility types
  • Write tests that verify your types
  • Refactor confidently with type safety

Happy typing with TypeScript! 🚀