Table of Contents
- Setting Up a TypeScript Project
- Core Concepts with Real-World Context
- Real-World Use Case 1: API Integration
- Real-World Use Case 2: State Management (React + TypeScript)
- Real-World Use Case 3: Custom Error Handling
- Leveraging TypeScript Utility Types
- Testing with TypeScript
- Advanced Use Cases
- Conclusion
- 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
TodoActiontypes are dispatched. state.todosis always an array ofTodoobjects.action.payloadmatches the expected type for each action (e.g.,TOGGLE_TODOrequires anumberpayload).
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 fromT.Omit<T, K>: Remove properties fromT.
// 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!