javascriptroom guide

Practical TypeScript: Real-world Examples and Use Cases

TypeScript has rapidly become a cornerstone of modern web development, loved for its ability to add static typing to JavaScript while preserving flexibility. By catching errors at compile time, improving code readability, and enabling better tooling, TypeScript reduces bugs in production and makes large codebases more maintainable. But while TypeScript’s basics (types, interfaces, generics) are well-documented, many developers struggle to apply these concepts to real-world scenarios. This blog bridges that gap: we’ll explore practical, hands-on examples of TypeScript in action—from project setup to advanced patterns like decorators and utility types. Whether you’re building APIs, state management systems, or testing utilities, you’ll learn how TypeScript solves common development challenges.

Table of Contents

  1. Setting Up a TypeScript Project
  2. Core Concepts with Real-World Context
  3. Real-World Use Case 1: API Integration
  4. Real-World Use Case 2: State Management (React + TypeScript)
  5. Real-World Use Case 3: Custom Error Handling
  6. Leveraging TypeScript Utility Types
  7. Testing with TypeScript
  8. Advanced Use Cases
  9. Conclusion
  10. References

1. Setting Up a TypeScript Project

Before diving into examples, let’s set up a TypeScript project. This foundational step ensures you’re configured to leverage TypeScript’s features effectively.

Step 1: Initialize a Project

Start with a new npm project and install TypeScript as a dev dependency:

mkdir practical-typescript && cd practical-typescript  
npm init -y  
npm install typescript --save-dev  

Step 2: Configure tsconfig.json

TypeScript uses tsconfig.json to define compiler options. Run npx tsc --init to generate a default config. Here are key settings to customize for real-world projects:

{  
  "compilerOptions": {  
    "target": "ES2020", // Compile to modern JS (adjust based on target environments)  
    "module": "ESNext", // Use ES modules (for tree-shaking, ESM support)  
    "outDir": "./dist", // Output compiled JS here  
    "rootDir": "./src", // Source files live here  
    "strict": true, // Enable all strict type-checking options (critical for safety!)  
    "esModuleInterop": true, // Simplify interop between CommonJS and ESM  
    "skipLibCheck": true, // Skip type-checking for node_modules (faster builds)  
    "forceConsistentCasingInFileNames": true // Avoid OS-specific casing issues  
  },  
  "include": ["src/**/*"], // Include all files in src/  
  "exclude": ["node_modules", "dist"] // Exclude dependencies and output  
}  

Step 3: Hello World Example

Create src/index.ts:

function greet(name: string): string {  
  return `Hello, ${name}!`;  
}  

const message = greet("TypeScript");  
console.log(message); // Output: Hello, TypeScript!  

Compile with npx tsc and run with node dist/index.js. TypeScript enforces that greet is called with a string argument—try passing a number, and it will throw a compile-time error!

2. Core Concepts with Real-World Context

TypeScript’s power lies in its type system. Let’s break down key concepts with practical examples.

Interfaces vs. Type Aliases

Both interface and type define shapes for data, but they have subtle differences.

When to Use interface:

  • Defining object shapes (e.g., API responses, state).
  • Extending or implementing in classes.
// Define a User interface for API responses  
interface User {  
  id: number;  
  name: string;  
  email: string;  
  createdAt: Date;  
}  

// Extend the interface for admin users  
interface AdminUser extends User {  
  role: "admin";  
  permissions: string[];  
}  

const admin: AdminUser = {  
  id: 1,  
  name: "Alice",  
  email: "[email protected]",  
  createdAt: new Date(),  
  role: "admin", // Must be "admin" (string literal type)  
  permissions: ["delete", "edit"]  
};  

When to Use type:

  • Union types, tuples, or primitive aliases.
  • Complex types that can’t be expressed with interface.
// Union type for API statuses  
type ApiStatus = "success" | "error" | "loading";  

// Tuple for coordinates  
type Coordinates = [number, number]; // [latitude, longitude]  

// Type alias for a function  
type GreetFunction = (name: string) => string;  

const greet: GreetFunction = (name) => `Hello, ${name}`;  

Generics: Reusable, Type-Safe Components

Generics let you write code that works with multiple types without sacrificing type safety. They’re indispensable for libraries, utilities, and reusable components.

Example: API Response Wrapper

APIs often return data with a consistent structure (e.g., { data: T, status: string }). Use generics to create a reusable ApiResponse type:

// Generic API response type  
type ApiResponse<T> = {  
  data: T;  
  status: "success" | "error";  
  message?: string;  
};  

// Fetch users from an API  
async function fetchUsers(): Promise<ApiResponse<User[]>> {  
  const response = await fetch("/api/users");  
  const data = await response.json();  
  return data; // TypeScript ensures data matches ApiResponse<User[]>  
}  

// Usage: Type-safe access to data  
fetchUsers().then((res) => {  
  if (res.status === "success") {  
    res.data.forEach(user => console.log(user.name)); // user is typed as User  
  }  
});  

Here, T is a placeholder for the data type (e.g., User[]). TypeScript infers T when you call fetchUsers, ensuring res.data is correctly typed.

3. Real-World Use Case 1: API Integration

TypeScript shines when working with external APIs, as it enforces consistency between request/response shapes and your code.

Example: REST API Client

Let’s build a type-safe client for a TODO API.

Step 1: Define Interfaces for Requests/Responses

// TODO interface (matches API response)  
interface Todo {  
  id: number;  
  title: string;  
  completed: boolean;  
  userId: number;  
}  

// Request body for creating a TODO  
interface CreateTodoRequest {  
  title: string;  
  completed?: boolean; // Optional (default: false)  
  userId: number;  
}  

Step 2: Type-Safe API Functions

class TodoApiClient {  
  private baseUrl: string;  

  constructor(baseUrl: string) {  
    this.baseUrl = baseUrl;  
  }  

  // Get all TODOs (returns Promise<ApiResponse<Todo[]>>)  
  async getTodos(): Promise<ApiResponse<Todo[]>> {  
    const response = await fetch(`${this.baseUrl}/todos`);  
    if (!response.ok) throw new Error("Failed to fetch todos");  
    return response.json();  
  }  

  // Create a new TODO (accepts CreateTodoRequest)  
  async createTodo(todo: CreateTodoRequest): Promise<ApiResponse<Todo>> {  
    const response = await fetch(`${this.baseUrl}/todos`, {  
      method: "POST",  
      headers: { "Content-Type": "application/json" },  
      body: JSON.stringify(todo),  
    });  
    if (!response.ok) throw new Error("Failed to create todo");  
    return response.json();  
  }  
}  

Step 3: Usage with Type Safety

const client = new TodoApiClient("https://jsonplaceholder.typicode.com");  

// TypeScript ensures `newTodo` matches CreateTodoRequest  
const newTodo: CreateTodoRequest = {  
  title: "Learn TypeScript",  
  userId: 1,  
};  

client.createTodo(newTodo).then((res) => {  
  if (res.status === "success") {  
    console.log("Created TODO:", res.data.id); // res.data is Todo  
  }  
});  

TypeScript prevents mistakes like missing title in newTodo or accessing non-existent properties on res.data.

4. Real-World Use Case 2: State Management (React + TypeScript)

TypeScript is a game-changer for React state management, ensuring your state and actions are type-safe. Let’s use useReducer (a common alternative to Redux) to manage a TODO list.

Step 1: Define State and Action Types

// TODO interface (reused from API example)  
interface Todo {  
  id: number;  
  title: string;  
  completed: boolean;  
}  

// State shape  
interface TodoState {  
  todos: Todo[];  
  loading: boolean;  
  error: string | null;  
}  

// Action types (string literals for type safety)  
type TodoAction =  
  | { type: "FETCH_TODOS_START" }  
  | { type: "FETCH_TODOS_SUCCESS"; payload: Todo[] }  
  | { type: "FETCH_TODOS_ERROR"; payload: string }  
  | { type: "ADD_TODO"; payload: Todo }  
  | { type: "TOGGLE_TODO"; payload: number }; // payload is todo id  

Step 2: Create the Reducer

function todoReducer(state: TodoState, action: TodoAction): TodoState {  
  switch (action.type) {  
    case "FETCH_TODOS_START":  
      return { ...state, loading: true, error: null };  
    case "FETCH_TODOS_SUCCESS":  
      return { ...state, loading: false, todos: action.payload };  
    case "FETCH_TODOS_ERROR":  
      return { ...state, loading: false, error: action.payload };  
    case "ADD_TODO":  
      return { ...state, todos: [...state.todos, action.payload] };  
    case "TOGGLE_TODO":  
      return {  
        ...state,  
        todos: state.todos.map(todo =>  
          todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo  
        ),  
      };  
    default:  
      // TypeScript throws an error if an action type is missing!  
      const exhaustiveCheck: never = action;  
      throw new Error(`Unhandled action type: ${exhaustiveCheck}`);  
  }  
}  

Step 3: Use in a React Component

import { useReducer, useEffect } from "react";  

const TodoList = () => {  
  const [state, dispatch] = useReducer(todoReducer, {  
    todos: [],  
    loading: false,  
    error: null,  
  });  

  useEffect(() => {  
    dispatch({ type: "FETCH_TODOS_START" });  
    client.getTodos().then((res) => {  
      if (res.status === "success") {  
        dispatch({ type: "FETCH_TODOS_SUCCESS", payload: res.data });  
      }  
    }).catch((err) => {  
      dispatch({ type: "FETCH_TODOS_ERROR", payload: err.message });  
    });  
  }, []);  

  if (state.loading) return <div>Loading...</div>;  
  if (state.error) return <div>Error: {state.error}</div>;  

  return (  
    <ul>  
      {state.todos.map(todo => (  
        <li key={todo.id} style={{ textDecoration: todo.completed ? "line-through" : "none" }}>  
          {todo.title}  
          <button onClick={() => dispatch({ type: "TOGGLE_TODO", payload: todo.id })}>  
            Toggle  
          </button>  
        </li>  
      ))}  
    </ul>  
  );  
};  

TypeScript ensures:

  • Only valid TodoAction types are dispatched.
  • state.todos is always an array of Todo objects.
  • action.payload matches the expected type for each action (e.g., TOGGLE_TODO requires a number payload).

5. Real-World Use Case 3: Custom Error Handling

TypeScript helps formalize error handling by defining custom error types, making it easier to distinguish between error scenarios (e.g., validation vs. network errors).

Example: Custom API Errors

// Base error class with status code  
class AppError extends Error {  
  statusCode: number;  

  constructor(message: string, statusCode: number) {  
    super(message);  
    this.name = this.constructor.name;  
    this.statusCode = statusCode;  
    Error.captureStackTrace(this, this.constructor); // Preserve stack trace  
  }  
}  

// Validation errors (e.g., missing required fields)  
class ValidationError extends AppError {  
  constructor(message: string) {  
    super(message, 400); // 400 Bad Request  
  }  
}  

// HTTP errors (e.g., 404 Not Found)  
class HttpError extends AppError {  
  constructor(message: string, statusCode: number) {  
    super(message, statusCode);  
  }  
}  

Usage in API Calls

async function createTodo(todo: CreateTodoRequest): Promise<ApiResponse<Todo>> {  
  if (!todo.title) {  
    throw new ValidationError("Title is required"); // Type-safe error  
  }  

  const response = await fetch(`${this.baseUrl}/todos`, { /* ... */ });  

  if (!response.ok) {  
    if (response.status === 404) {  
      throw new HttpError("API endpoint not found", 404);  
    } else {  
      throw new HttpError("Failed to create todo", response.status);  
    }  
  }  

  return response.json();  
}  

Catching Typed Errors

try {  
  await createTodo({ userId: 1 }); // Missing title → throws ValidationError  
} catch (err) {  
  if (err instanceof ValidationError) {  
    console.error("Validation failed:", err.message); // err.statusCode is 400  
  } else if (err instanceof HttpError) {  
    console.error(`HTTP Error ${err.statusCode}:`, err.message);  
  } else {  
    console.error("Unknown error:", err);  
  }  
}  

TypeScript ensures you can safely check err instanceof to handle specific error types.

6. Leveraging TypeScript Utility Types

TypeScript provides built-in utility types to transform existing types, reducing boilerplate. Here are the most useful ones with real-world examples.

Partial<T>: Make All Properties Optional

Useful for updating objects (e.g., PATCH requests where only some fields change).

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

// Allow partial updates (e.g., update name or email, not all fields)  
type UserUpdate = Partial<User>;  

function updateUser(user: User, changes: UserUpdate): User {  
  return { ...user, ...changes };  
}  

// Valid: Only update email  
const updatedUser = updateUser(  
  { id: 1, name: "Bob", email: "[email protected]" },  
  { email: "[email protected]" }  
);  

Required<T>: Make All Properties Required

Opposite of Partial—useful for enforcing mandatory configs.

type Config = {  
  apiUrl?: string;  
  timeout?: number;  
};  

// Ensure config has all required fields  
type RequiredConfig = Required<Config>;  

function initialize(config: RequiredConfig) {  
  console.log("API URL:", config.apiUrl); // No need for optional chaining!  
}  

initialize({ apiUrl: "https://api.example.com", timeout: 5000 }); // All fields required  

Pick<T, K> and Omit<T, K>: Select/Remove Properties

  • Pick<T, K>: Select a subset of properties from T.
  • Omit<T, K>: Remove properties from T.
// Pick: Only include id and name (for public user profiles)  
type PublicUser = Pick<User, "id" | "name">;  

// Omit: Exclude email (for privacy)  
type UserWithoutEmail = Omit<User, "email">;  

const publicUser: PublicUser = { id: 1, name: "Alice" }; // email is excluded  

ReturnType<T>: Infer the Return Type of a Function

Useful for extracting types from existing functions (e.g., API response types).

// Function to fetch a user  
function fetchUser(id: number): Promise<User> {  
  return fetch(`/api/users/${id}`).then(res => res.json());  
}  

// Infer the return type of fetchUser (Promise<User>)  
type FetchUserResult = ReturnType<typeof fetchUser>; // Promise<User>  

7. Testing with TypeScript

TypeScript ensures your tests are type-safe, catching issues like incorrect function arguments or missing assertions early. Let’s use Jest to test a utility function.

Example: Testing a Sum Function

// src/utils/sum.ts  
export function sum(a: number, b: number): number {  
  return a + b;  
}  

Test File (sum.test.ts)

import { sum } from "./sum";  

describe("sum", () => {  
  it("adds two numbers", () => {  
    expect(sum(1, 2)).toBe(3); // OK  
    expect(sum(5, -3)).toBe(2); // OK  
  });  

  it("throws an error for non-number inputs", () => {  
    // TypeScript catches these! (Uncomment to see errors)  
    // sum("1", 2); // Argument of type 'string' is not assignable to parameter of type 'number'  
    // sum(1, "2"); // Same error  
  });  
});  

Run tests with npx jest. TypeScript ensures:

  • Tests pass valid arguments to sum.
  • Assertions (e.g., toBe(3)) use the correct type (number).

8. Advanced Use Cases

Decorators for Logging/Validation

Decorators (experimental in TypeScript 5.2+) let you add metadata or behavior to classes/methods. Enable them in tsconfig.json:

{  
  "compilerOptions": {  
    "experimentalDecorators": true,  
    "emitDecoratorMetadata": true  
  }  
}  

Example: Method Logging Decorator

// Decorator to log method calls  
function logMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor): void {  
  const originalMethod = descriptor.value;  

  descriptor.value = function(...args: any[]) {  
    console.log(`Calling ${propertyKey} with args:`, args);  
    const result = originalMethod.apply(this, args);  
    console.log(`Method ${propertyKey} returned:`, result);  
    return result;  
  };  
}  

class Calculator {  
  @logMethod // Apply decorator  
  add(a: number, b: number): number {  
    return a + b;  
  }  
}  

const calc = new Calculator();  
calc.add(2, 3);  
// Output:  
// Calling add with args: [2, 3]  
// Method add returned: 5  

Generics in Data Structures

Generics enable type-safe data structures like stacks, queues, or linked lists.

Example: Generic Stack Class

class Stack<T> {  
  private items: T[] = [];  

  push(item: T): void {  
    this.items.push(item);  
  }  

  pop(): T | undefined {  
    return this.items.pop();  
  }  

  peek(): T | undefined {  
    return this.items[this.items.length - 1];  
  }  

  isEmpty(): boolean {  
    return this.items.length === 0;  
  }  
}  

// Stack of numbers  
const numberStack = new Stack<number>();  
numberStack.push(1);  
numberStack.push(2);  
console.log(numberStack.pop()); // 2 (type: number)  

// Stack of strings  
const stringStack = new Stack<string>();  
stringStack.push("hello");  
console.log(stringStack.peek()); // "hello" (type: string)  

Conclusion

TypeScript transforms JavaScript from a dynamically typed language into a robust, maintainable tool for large-scale applications. By enforcing type safety, enabling reusable components with generics, and formalizing patterns like error handling and state management, TypeScript reduces bugs, improves collaboration, and accelerates development.

Whether you’re building APIs, React apps, or utility libraries, TypeScript’s practical features—from interfaces to utility types—provide immediate value. Start small (e.g., adding types to a single function) and gradually adopt more advanced patterns like generics or decorators. Your future self (and teammates) will thank you!

References