Welcome to your first major project in our journey to TypeScript mastery! So far, we’ve explored the foundational concepts, advanced types, and best practices of TypeScript. Now, it’s time to put all that knowledge into action by building a practical, real-world application.

In this chapter, we’re going to construct a robust, type-safe REST API using Node.js and the popular Express.js framework, all powered by TypeScript. This project will solidify your understanding of how TypeScript enhances developer experience, prevents common bugs, and improves the maintainability of backend services. Get ready to build something awesome!

Introduction to Building a Type-Safe REST API

Building an API (Application Programming Interface) is a fundamental skill for modern developers. It allows different software components to communicate with each other, forming the backbone of most web and mobile applications. A REST (Representational State Transfer) API follows a set of architectural principles, using standard HTTP methods (GET, POST, PUT, DELETE) to perform operations on resources.

Why build this with TypeScript? Because without it, a Node.js API can quickly become a tangled mess of implicit assumptions about data shapes. TypeScript brings clarity, confidence, and consistency, ensuring that the data flowing through your API matches your expectations. You’ll catch errors before your code even runs, which is a massive win for productivity and stability.

To get the most out of this chapter, you should be comfortable with:

  • Basic TypeScript syntax (interfaces, types, functions).
  • Fundamentals of Node.js and npm/yarn.
  • Basic HTTP concepts (methods, status codes).
  • Understanding of modular programming (import/export).

Don’t worry if some of these feel a little shaky; we’ll reinforce them as we go!

Core Concepts for Our API Project

Before we dive into the code, let’s lay down the foundational concepts. Understanding why we’re choosing certain tools and patterns will make the how much clearer.

What is a REST API?

Imagine you’re ordering food online. When you request the menu, you’re making a GET request. When you add an item to your cart, that might be a POST request. If you update the quantity of an item, a PUT or PATCH request. And if you remove an item, a DELETE request.

A REST API works similarly:

  • Resources: These are the “things” your API manages (e.g., users, products, orders).
  • HTTP Methods:
    • GET: Retrieve data.
    • POST: Create new data.
    • PUT/PATCH: Update existing data.
    • DELETE: Remove data.
  • Endpoints: Unique URLs that identify resources (e.g., /api/users, /api/products/123).
  • Statelessness: Each request from a client to a server must contain all the information needed to understand the request. The server doesn’t “remember” previous requests.

Why Node.js and Express.js?

Node.js is a JavaScript runtime built on Chrome’s V8 JavaScript engine. It allows us to run JavaScript on the server side. Its non-blocking, event-driven architecture makes it incredibly efficient for I/O-heavy applications like APIs.

Express.js is a minimalist, flexible Node.js web application framework that provides a robust set of features for web and mobile applications. It simplifies tasks like routing, middleware integration, and handling HTTP requests and responses. It’s the de-facto standard for building REST APIs with Node.js.

Why TypeScript for APIs? The Type-Safety Advantage

You might be thinking, “Can’t I just build this with plain JavaScript?” Yes, you can! But here’s why TypeScript is a game-changer for APIs:

  1. Data Integrity: APIs deal with data constantly. TypeScript allows you to define the exact shape of your incoming requests and outgoing responses. This means if your frontend expects a User object with id and name, TypeScript will ensure your backend sends that, and will warn you if you try to send something else.
  2. Early Error Detection: Many common API bugs (e.g., typos in property names, missing required fields) are caught by TypeScript before you even run your code. This saves countless hours of debugging.
  3. Improved Refactoring: As your API grows, you’ll inevitably need to change data models or endpoint logic. TypeScript’s static analysis helps you refactor with confidence, highlighting all places affected by a change.
  4. Better Collaboration: When working in a team, TypeScript acts as living documentation, making it clear what data types functions expect and return. This reduces miscommunication and integration issues.
  5. Enhanced Developer Experience: With strong typing, your IDE can provide intelligent autocomplete, signature help, and inline error checking, making coding faster and more enjoyable.

Project Structure: Keeping Things Tidy

A well-organized project is crucial for maintainability. For our API, we’ll adopt a common, logical structure:

my-ts-api/
├── src/
│   ├── controllers/    // Contains the logic for handling requests (e.g., createUser)
│   ├── data/           // Our "in-memory database" for this project
│   ├── models/         // Defines the shapes of our data (interfaces)
│   ├── routes/         // Maps URLs to controller functions
│   └── app.ts          // The main entry point of our Express application
├── node_modules/       // Where npm installs packages
├── package.json        // Project metadata and scripts
├── tsconfig.json       // TypeScript compiler configuration
└── .gitignore          // Specifies files/folders to ignore in Git

This structure separates concerns, making it easier to find, understand, and modify different parts of your API.

Step-by-Step Implementation

Alright, enough talk! Let’s get our hands dirty and start building our type-safe REST API.

Step 1: Project Setup and Dependencies

First, we need to set up our project directory and install the necessary tools.

  1. Create Project Directory: Open your terminal or command prompt and create a new folder for our project.

    mkdir my-ts-api
    cd my-ts-api
    
  2. Initialize Node.js Project: This command creates a package.json file, which manages our project’s metadata and dependencies. The -y flag answers “yes” to all prompts, creating a default package.json.

    npm init -y
    

    Go ahead and open package.json. You’ll see basic information about your project.

  3. Install TypeScript: Now, let’s install TypeScript. As of 2025-12-05, the latest stable version of TypeScript is 5.9.3. We’ll install it as a development dependency (-D or --save-dev) because it’s used during development to compile our code, but not needed at runtime in the final compiled JavaScript.

    npm install -D typescript@5.9.3
    

    You can always verify the latest version on the official TypeScript GitHub: https://github.com/microsoft/TypeScript

  4. Initialize TypeScript Configuration: We need a tsconfig.json file to tell the TypeScript compiler (tsc) how to compile our TypeScript code into JavaScript.

    npx tsc --init
    

    This command creates a tsconfig.json file in your project root. Let’s open it and make a few important adjustments.

    Find and uncomment/modify these lines in your tsconfig.json:

    // tsconfig.json
    {
      "compilerOptions": {
        "target": "ES2022",                                  /* Specify what JS language version to compile to. Latest is good! */
        "module": "Node16",                                /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', 'es2022', 'nodenext', 'none', 'preserve', 'react-native', 'react-jsx', 'react-jsxdev', 'bundler'. */
        "outDir": "./dist",                                 /* Specify an output folder for all emitted files. */
        "rootDir": "./src",                                 /* Specify the root folder within your source files. */
        "esModuleInterop": true,                            /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
        "forceConsistentCasingInFileNames": true,           /* Ensure that casing is consistent across all file systems. */
        "strict": true,                                     /* Enable all strict type-checking options. */
        "skipLibCheck": true                                /* Skip type checking all .d.ts files. */
      },
      "include": ["src/**/*.ts"],                           /* Specify which files to include in compilation. */
      "exclude": ["node_modules"]                           /* Specify files and folders that should be excluded from compilation. */
    }
    

    Explanation of key compilerOptions:

    • "target": "ES2022": Tells TypeScript to compile our code down to JavaScript that supports features up to ES2022. This is modern and widely supported by Node.js v25.2.1 (our context version).
    • "module": "Node16": Specifies the module system to use. Node16 is a modern choice for Node.js projects, supporting ESM (ECMAScript Modules) which is the standard.
    • "outDir": "./dist": All compiled JavaScript files will be placed in a dist (distribution) folder.
    • "rootDir": "./src": Our TypeScript source files will live in the src folder. This helps maintain a clean separation.
    • "esModuleInterop": true: This is super useful! It allows you to import CommonJS modules (like Express) using the modern ES Module import syntax (import express from 'express';) even if they don’t explicitly have a default export. Without it, you might need import * as express from 'express';.
    • "strict": true: Crucial for type safety! This enables a whole suite of strict type-checking options (like noImplicitAny, noImplicitReturns, strictNullChecks). Always enable this for robust TypeScript.
    • "skipLibCheck": true: Speeds up compilation by skipping type checking of declaration files (like those from node_modules).
  5. Install Express.js and its Type Definitions: We need Express itself, and because Express is written in JavaScript, TypeScript needs “declaration files” (or “type definitions”) to understand its types. These are usually found in the @types/ scope.

    npm install express@latest
    npm install -D @types/express@latest
    

    express@latest will install the most recent stable version (likely 4.x). @types/express@latest will provide type definitions corresponding to that version.

  6. Install Development Tools (ts-node, nodemon):

    • ts-node: Allows you to run TypeScript files directly without compiling them to JavaScript first. Great for development!
    • nodemon: Automatically restarts your Node.js application when it detects file changes. Perfect for a smooth development workflow.
    npm install -D ts-node@latest nodemon@latest
    
  7. Add Development Scripts to package.json: Open package.json again. Under the "scripts" section, add these two entries:

    // package.json
    {
      "name": "my-ts-api",
      "version": "1.0.0",
      "description": "",
      "main": "index.js",
      "scripts": {
        "start": "node dist/app.js",        // For running compiled JS in production
        "dev": "nodemon --exec ts-node src/app.ts", // For development with hot-reloading
        "build": "tsc"                      // To compile TypeScript to JavaScript
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "devDependencies": {
        "@types/express": "^4.17.21",
        "nodemon": "^3.0.3",
        "ts-node": "^10.9.2",
        "typescript": "^5.9.3"
      },
      "dependencies": {
        "express": "^4.19.2"
      }
    }
    

    (Note: Version numbers in your package.json might be slightly newer than shown here due to @latest installs, which is perfectly fine!)

    Explanation of scripts:

    • npm run dev: This will use nodemon to watch our src folder. Whenever we save a .ts file, ts-node will re-execute src/app.ts, restarting our server. Super convenient!
    • npm run build: This will simply run the TypeScript compiler (tsc), which will compile all our .ts files from src into .js files in the dist directory according to our tsconfig.json.
    • npm run start: This is how we’d run our compiled application in a production environment.

Step 2: Basic Server Setup (src/app.ts)

Now, let’s create the entry point of our Express application.

  1. Create src Directory:

    mkdir src
    
  2. Create src/app.ts: Inside the src folder, create a new file named app.ts. This will be our main application file.

    // src/app.ts
    import express from 'express';
    
    // 1. Create an Express application instance
    const app = express();
    const PORT = process.env.PORT || 3000; // Define the port, using environment variable or default to 3000
    
    // 2. Define a simple route for the root URL
    app.get('/', (req, res) => {
      res.send('Welcome to our Type-Safe REST API!');
    });
    
    // 3. Start the server and listen for incoming requests
    app.listen(PORT, () => {
      console.log(`Server is running on http://localhost:${PORT}`);
      console.log('Press Ctrl+C to stop the server.');
    });
    

    Code Explanation:

    • import express from 'express';: This line imports the Express.js library. Thanks to esModuleInterop: true in tsconfig.json, we can use this clean ES Module syntax.
    • const app = express();: We create an instance of the Express application. This app object is where we’ll configure our routes, middleware, and other server settings.
    • const PORT = process.env.PORT || 3000;: We define the port our server will listen on. It’s good practice to use process.env.PORT for deployment environments and fall back to a default (like 3000) for local development.
    • app.get('/', (req, res) => { ... });: This sets up a “route handler”.
      • app.get(): Specifies that this handler should respond to GET requests.
      • /: This is the path, meaning the root URL of our API.
      • (req, res) => { ... }: This is the callback function that gets executed when a GET request hits the / path.
        • req: The request object, containing information about the incoming HTTP request.
        • res: The response object, used to send a response back to the client.
      • res.send('Welcome to our Type-Safe REST API!');: We use res.send() to send a simple string as the response.
    • app.listen(PORT, () => { ... });: This starts the Express server. It listens for incoming HTTP requests on the specified PORT. The callback function is executed once the server successfully starts.
  3. Run Your Server: Now, let’s see our server in action!

    npm run dev
    

    You should see output like:

    [nodemon] 3.0.3
    [nodemon] to restart at any time, enter `rs`
    [nodemon] watching path(s): *.*
    [nodemon] watching extensions: ts,json
    [nodemon] starting `ts-node src/app.ts`
    Server is running on http://localhost:3000
    Press Ctrl+C to stop the server.
    

    Open your web browser and navigate to http://localhost:3000. You should see the “Welcome to our Type-Safe REST API!” message. Awesome! Our basic server is up and running.

Step 3: Defining Data Models (Interfaces)

Before we start handling users, let’s define what a “user” looks like in our application. This is where TypeScript truly shines!

  1. Create src/models Directory:

    mkdir src/models
    
  2. Create src/models/user.model.ts: Inside src/models, create user.model.ts and add the following interface:

    // src/models/user.model.ts
    
    /**
     * @interface User
     * @description Defines the structure for a user object in our application.
     * This ensures all user data conforms to a consistent shape.
     */
    export interface User {
      id: string;      // A unique identifier for the user
      name: string;    // The user's full name
      email: string;   // The user's email address, expected to be unique
      age?: number;    // Optional: The user's age
    }
    
    /**
     * @interface CreateUserDTO
     * @description Data Transfer Object (DTO) for creating a new user.
     * 'id' is omitted as it will be generated by the system.
     */
    export type CreateUserDTO = Omit<User, 'id'>;
    
    /**
     * @interface UpdateUserDTO
     * @description Data Transfer Object (DTO) for updating an existing user.
     * All fields are optional, allowing partial updates.
     */
    export type UpdateUserDTO = Partial<CreateUserDTO>;
    

    Code Explanation:

    • export interface User { ... }: We define an interface named User. This is a blueprint for what a User object must look like.
      • id: string;: Every user must have an id property, and it must be a string.
      • name: string;: Same for name.
      • email: string;: And email.
      • age?: number;: The ? makes age an optional property. If it exists, it must be a number.
    • export type CreateUserDTO = Omit<User, 'id'>;: Here we introduce a DTO (Data Transfer Object). When we create a user, we don’t send the id from the client; the server generates it. Omit<User, 'id'> is a powerful TypeScript utility type that creates a new type by taking all properties from User except id.
    • export type UpdateUserDTO = Partial<CreateUserDTO>;: For updating a user, we might only send a few fields (e.g., just the name). Partial<CreateUserDTO> is another utility type that makes all properties of CreateUserDTO optional.

    These interfaces provide strong type guarantees for all user-related data throughout our API, from incoming requests to internal data handling.

Step 4: Creating a “Database” (In-memory Array)

For simplicity in this introductory project, we’ll simulate a database using a simple in-memory array. In future chapters, we’ll integrate real databases!

  1. Create src/data Directory:

    mkdir src/data
    
  2. Create src/data/users.ts: Inside src/data, create users.ts and populate it with some initial user data.

    // src/data/users.ts
    import { User } from '../models/user.model'; // Import our User interface
    
    /**
     * @description An in-memory array to simulate our user database.
     * This array is typed as `User[]`, ensuring all elements conform to the User interface.
     */
    export const users: User[] = [
      { id: '1', name: 'Alice Smith', email: 'alice@example.com', age: 30 },
      { id: '2', name: 'Bob Johnson', email: 'bob@example.com', age: 24 },
      { id: '3', name: 'Charlie Brown', email: 'charlie@example.com' }, // Age is optional
    ];
    

    Code Explanation:

    • import { User } from '../models/user.model';: We import our User interface so we can use it to type our users array.
    • export const users: User[] = [...]: We declare a constant array named users and explicitly type it as User[]. This tells TypeScript that every element in this array must conform to the User interface. If you tried to add an object like { name: 'Dave' } (missing id and email), TypeScript would immediately flag it as an error!

Step 5: Building Controllers

Controllers are responsible for the business logic of our API. They receive requests, interact with our data source (the users array for now), and send back responses.

  1. Create src/controllers Directory:

    mkdir src/controllers
    
  2. Create src/controllers/user.controller.ts: Inside src/controllers, create user.controller.ts. This file will contain functions for each API operation on users.

    // src/controllers/user.controller.ts
    import { Request, Response } from 'express'; // Import Request and Response types from Express
    import { users } from '../data/users';      // Our in-memory user data
    import { User, CreateUserDTO, UpdateUserDTO } from '../models/user.model'; // Our user interfaces
    import { v4 as uuidv4 } from 'uuid';       // For generating unique IDs (install later)
    
    /**
     * @function getAllUsers
     * @description Handles GET requests to retrieve all users.
     * @param req - The Express request object.
     * @param res - The Express response object.
     */
    export const getAllUsers = (req: Request, res: Response<User[]>) => {
      // TypeScript ensures `users` is an array of User objects.
      res.status(200).json(users);
    };
    
    /**
     * @function getUserById
     * @description Handles GET requests to retrieve a single user by ID.
     * @param req - The Express request object (with `id` in params).
     * @param res - The Express response object.
     */
    export const getUserById = (req: Request<{ id: string }>, res: Response<User | { message: string }>) => {
      const { id } = req.params; // Extract the ID from URL parameters
      const user = users.find(u => u.id === id); // Find the user
    
      if (user) {
        res.status(200).json(user); // If found, send the user object
      } else {
        res.status(404).json({ message: 'User not found' }); // If not found, send a 404
      }
    };
    
    /**
     * @function createUser
     * @description Handles POST requests to create a new user.
     * @param req - The Express request object (with CreateUserDTO in body).
     * @param res - The Express response object.
     */
    export const createUser = (req: Request<{}, {}, CreateUserDTO>, res: Response<User | { message: string }>) => {
      const newUserInput: CreateUserDTO = req.body; // TypeScript ensures req.body matches CreateUserDTO
    
      // Basic validation: ensure required fields are present
      if (!newUserInput.name || !newUserInput.email) {
        return res.status(400).json({ message: 'Name and email are required.' });
      }
    
      // Check for duplicate email (simple in-memory check)
      if (users.some(u => u.email === newUserInput.email)) {
        return res.status(409).json({ message: 'User with this email already exists.' });
      }
    
      // Generate a unique ID for the new user
      const id = uuidv4();
      const newUser: User = { id, ...newUserInput }; // Create the new user object, typed as User
    
      users.push(newUser); // Add to our in-memory "database"
      res.status(201).json(newUser); // Send back the created user with 201 status
    };
    
    /**
     * @function updateUser
     * @description Handles PUT/PATCH requests to update an existing user.
     * @param req - The Express request object (with id in params and UpdateUserDTO in body).
     * @param res - The Express response object.
     */
    export const updateUser = (req: Request<{ id: string }, {}, UpdateUserDTO>, res: Response<User | { message: string }>) => {
      const { id } = req.params;
      const updatedFields: UpdateUserDTO = req.body; // TypeScript ensures req.body matches UpdateUserDTO
    
      const userIndex = users.findIndex(u => u.id === id);
    
      if (userIndex !== -1) {
        // Create a new user object with updated fields
        const updatedUser: User = {
          ...users[userIndex], // Start with existing user data
          ...updatedFields     // Overlay with updated fields
        };
    
        // Check for duplicate email if email is being updated
        if (updatedFields.email && updatedFields.email !== users[userIndex].email && users.some(u => u.email === updatedFields.email && u.id !== id)) {
            return res.status(409).json({ message: 'Another user with this email already exists.' });
        }
    
        users[userIndex] = updatedUser; // Replace the old user with the updated one
        res.status(200).json(updatedUser); // Send back the updated user
      } else {
        res.status(404).json({ message: 'User not found' });
      }
    };
    
    /**
     * @function deleteUser
     * @description Handles DELETE requests to remove a user by ID.
     * @param req - The Express request object (with id in params).
     * @param res - The Express response object.
     */
    export const deleteUser = (req: Request<{ id: string }>, res: Response<{ message: string }>) => {
      const { id } = req.params;
      const initialLength = users.length;
      const usersAfterDeletion = users.filter(u => u.id !== id);
    
      // If length changed, a user was deleted
      if (usersAfterDeletion.length < initialLength) {
        // Update the original array (since we're modifying it in place)
        users.splice(0, users.length, ...usersAfterDeletion);
        res.status(200).json({ message: 'User deleted successfully' });
      } else {
        res.status(404).json({ message: 'User not found' });
      }
    };
    

    Wait! Before running this, you’ll see a red squiggle under uuidv4. That’s because we haven’t installed the uuid package yet. Let’s do that now:

    npm install uuid@latest
    npm install -D @types/uuid@latest
    

    This installs the uuid library for generating unique identifiers and its type definitions.

    Let’s break down the controller code and the magic of TypeScript:

    • import { Request, Response } from 'express';: We import the Request and Response types provided by @types/express. These are incredibly powerful!
    • req: Request<{ id: string }>, res: Response<User | { message: string }>: Look at the type annotations for req and res!
      • Request<{ id: string }>: This tells TypeScript that the req.params object for this specific route must have an id property of type string. If you try to access req.params.userId or req.params.id.toFixed(), TypeScript will warn you!
      • Request<{}, {}, CreateUserDTO>: For createUser, the first two generic arguments are Params and Query (which we’re not using, hence {}), and the third is Body. This means req.body must conform to our CreateUserDTO interface. If a client sends a request body like { age: 25 } without name or email, TypeScript won’t catch it at runtime, but it ensures that within our code, we treat req.body as a CreateUserDTO. We still add runtime validation (if (!newUserInput.name || !newUserInput.email)) because data from the network is untrusted.
      • Response<User | { message: string }>: This defines the expected shape of the data we send back. For getUserById, we might send a User object (if found) or an object { message: string } (if not found). This type union helps consumers of our API understand what to expect.
    • Logic: Each function handles a specific API operation:
      • getAllUsers: Simply returns the entire users array.
      • getUserById: Finds a user by id from req.params.
      • createUser: Takes data from req.body (typed as CreateUserDTO), generates a new id using uuidv4(), creates a User object, and adds it to the users array. Includes basic validation.
      • updateUser: Finds a user by id, takes partial updates from req.body (typed as UpdateUserDTO), and merges them with the existing user data.
      • deleteUser: Removes a user from the array based on id.

This is where the power of TypeScript makes your API robust. You’re defining contracts for your data, and the compiler helps enforce them!

Step 6: Setting Up Routes

Routes define the API endpoints and link them to our controller functions.

  1. Create src/routes Directory:

    mkdir src/routes
    
  2. Create src/routes/user.routes.ts: Inside src/routes, create user.routes.ts.

    // src/routes/user.routes.ts
    import { Router } from 'express'; // Import Router from Express
    import {
      getAllUsers,
      getUserById,
      createUser,
      updateUser,
      deleteUser,
    } from '../controllers/user.controller'; // Import our controller functions
    
    // Create a new Express router instance
    const router = Router();
    
    /**
     * @description Define API routes for user resource.
     * Each route maps an HTTP method and path to a specific controller function.
     */
    router.get('/', getAllUsers);          // GET /api/users
    router.post('/', createUser);          // POST /api/users
    router.get('/:id', getUserById);       // GET /api/users/:id
    router.put('/:id', updateUser);        // PUT /api/users/:id
    router.patch('/:id', updateUser);      // PATCH /api/users/:id (often uses the same handler as PUT for full replacement or partial update)
    router.delete('/:id', deleteUser);     // DELETE /api/users/:id
    
    export default router; // Export the configured router
    

    Code Explanation:

    • import { Router } from 'express';: We import the Router class from Express. This allows us to create modular, mountable route handlers.
    • const router = Router();: We create an instance of an Express router.
    • router.get('/', getAllUsers);: This tells the router that when it receives a GET request to its base path (/), it should call the getAllUsers function from our controller.
    • router.get('/:id', getUserById);: The /:id part is a route parameter. Express will parse the URL (e.g., /api/users/123) and make id: '123' available in req.params.
    • We define routes for POST, PUT, PATCH, and DELETE as well, linking them to their respective controller functions.
    • export default router;: We export the configured router so our main app.ts file can use it.

Step 7: Integrating Routes into the Main Application

Finally, we need to tell our main Express application (app.ts) to use the user routes we just defined.

  1. Modify src/app.ts: Open src/app.ts again and update it as follows:

    // src/app.ts
    import express from 'express';
    import userRoutes from './routes/user.routes'; // Import our user routes
    
    const app = express();
    const PORT = process.env.PORT || 3000;
    
    // --- Add middleware here ---
    // Middleware to parse JSON bodies from incoming requests.
    // This is crucial for POST/PUT/PATCH requests where clients send JSON data.
    app.use(express.json());
    // --- End middleware ---
    
    // Mount the user routes under the /api/users path
    app.use('/api/users', userRoutes);
    
    // Default route (still keep for a simple welcome)
    app.get('/', (req, res) => {
      res.send('Welcome to our Type-Safe REST API!');
    });
    
    app.listen(PORT, () => {
      console.log(`Server is running on http://localhost:${PORT}`);
      console.log('Press Ctrl+C to stop the server.');
    });
    

    Code Explanation:

    • import userRoutes from './routes/user.routes';: We import the default export from user.routes.ts, which is our configured router.
    • app.use(express.json());: This is a crucial piece of middleware. Express applications process requests in a pipeline. express.json() is a built-in middleware function that parses incoming request bodies with JSON payloads. Without this, req.body would be undefined for POST and PUT requests.
    • app.use('/api/users', userRoutes);: This tells our Express app to use userRoutes for any requests that start with /api/users. So, a GET /api/users request will be handled by getAllUsers, and a POST /api/users will be handled by createUser, and so on.

Step 8: Testing Your API

Now that everything is wired up, let’s test our API! Ensure your server is running (npm run dev).

You can use tools like Postman, Insomnia, or even curl in your terminal. Here are some curl examples:

  1. GET all users:

    curl http://localhost:3000/api/users
    

    Expected output:

    [
      {"id":"1","name":"Alice Smith","email":"alice@example.com","age":30},
      {"id":"2","name":"Bob Johnson","email":"bob@example.com","age":24},
      {"id":"3","name":"Charlie Brown","email":"charlie@example.com"}
    ]
    
  2. GET user by ID:

    curl http://localhost:3000/api/users/1
    

    Expected output:

    {"id":"1","name":"Alice Smith","email":"alice@example.com","age":30}
    

    Try with a non-existent ID:

    curl http://localhost:3000/api/users/99
    

    Expected output:

    {"message":"User not found"}
    
  3. POST a new user:

    curl -X POST -H "Content-Type: application/json" -d '{"name": "David Lee", "email": "david@example.com", "age": 28}' http://localhost:3000/api/users
    

    Expected output (with a new generated ID):

    {"id":"<some-uuid>","name":"David Lee","email":"david@example.com","age":28}
    

    Now, if you GET all users again, David Lee should be in the list!

    Try to create a user with missing required fields:

    curl -X POST -H "Content-Type: application/json" -d '{"name": "Eve"}' http://localhost:3000/api/users
    

    Expected output:

    {"message":"Name and email are required."}
    
  4. PUT/PATCH an existing user: Let’s update Alice (assuming her ID is ‘1’).

    curl -X PUT -H "Content-Type: application/json" -d '{"name": "Alicia Smith-Jones", "age": 31}' http://localhost:3000/api/users/1
    

    Expected output:

    {"id":"1","name":"Alicia Smith-Jones","email":"alice@example.com","age":31}
    

    Notice how the email was not changed because we didn’t include it in the PUT body, but name and age were updated.

  5. DELETE a user: Let’s delete Bob (assuming his ID is ‘2’).

    curl -X DELETE http://localhost:3000/api/users/2
    

    Expected output:

    {"message":"User deleted successfully"}
    

    Verify by GETting all users. Bob should be gone!

Congratulations! You’ve just built a fully functional, type-safe REST API using Node.js, Express, and TypeScript. Take a moment to appreciate the power of types in ensuring your API handles data correctly.

Mini-Challenge: Extend the API with a New Resource

Now it’s your turn to apply what you’ve learned!

Challenge: Add a new resource to your API: Products.

Your product API should support the following operations:

  • GET /api/products: Retrieve all products.
  • GET /api/products/:id: Retrieve a single product by ID.
  • POST /api/products: Create a new product.
  • PUT /api/products/:id: Update an existing product.
  • DELETE /api/products/:id: Delete a product.

Hint: Follow the exact same modular pattern we used for users:

  1. Define a Product interface (and CreateProductDTO, UpdateProductDTO) in src/models/product.model.ts. A Product might have id: string, name: string, price: number, description?: string.
  2. Create an in-memory products array in src/data/products.ts.
  3. Implement product-specific controller functions (getAllProducts, getProductById, etc.) in src/controllers/product.controller.ts. Remember to use proper TypeScript types for req and res!
  4. Set up product routes in src/routes/product.routes.ts.
  5. Integrate the productRoutes into src/app.ts using app.use('/api/products', productRoutes);.

What to Observe/Learn:

  • How easily you can extend your API with new resources by following a consistent, modular pattern.
  • How TypeScript helps you define and enforce the structure of your Product data, just as it did for User data.
  • The benefits of separating concerns (models, data, controllers, routes).

Take your time, experiment, and don’t be afraid to make mistakes – that’s how we learn!

Common Pitfalls & Troubleshooting

Even with TypeScript, you might encounter some bumps along the road. Here are a few common pitfalls and how to address them:

  1. “Cannot find module ’express’ or its corresponding type declarations.”

    • Cause: You likely forgot to install @types/express.
    • Solution: Run npm install -D @types/express@latest. Always remember to install the @types/ package for any JavaScript library you use with TypeScript.
  2. req.body is undefined for POST/PUT requests.

    • Cause: You forgot to include the express.json() middleware in src/app.ts.
    • Solution: Add app.use(express.json()); before you define your routes in src/app.ts.
  3. TypeScript complains about req.params.id not existing or having the wrong type.

    • Cause: You might not have correctly typed the Request object in your controller function.
    • Solution: Ensure you’re using the generic type for Request. For example, (req: Request<{ id: string }>, res: Response) => { ... } explicitly tells TypeScript that req.params will have an id property of type string.
  4. “Property ‘xyz’ does not exist on type ‘ABC’.”

    • Cause: You’re trying to access a property that TypeScript doesn’t know exists on an object, or the object has a different type than you expect.
    • Solution:
      • Check your interfaces (User, CreateUserDTO, etc.). Is the property defined there?
      • If it’s an optional property, remember to handle its potential absence (e.g., if (user.age) { ... }).
      • If data comes from an external source (like req.body), even with type hints, TypeScript can’t guarantee runtime safety. You might need runtime validation or type assertions (req.body as CreateUserDTO) after validation, though good typing reduces the need for frequent assertions.
  5. nodemon isn’t restarting or ts-node isn’t working.

    • Cause: Misconfigured package.json scripts or tsconfig.json.
    • Solution:
      • Double-check your dev script in package.json: nodemon --exec ts-node src/app.ts.
      • Ensure ts-node and nodemon are installed as dev dependencies.
      • Verify tsconfig.json’s rootDir and outDir are correct relative to your src folder.

Summary

Phew! You’ve just completed your first major project, building a type-safe REST API. Here’s a quick recap of what you’ve accomplished and learned:

  • Project Setup: You initialized a Node.js project, installed TypeScript, Express, and essential development tools like ts-node and nodemon.
  • TypeScript Configuration: You configured tsconfig.json with modern settings (target, module, outDir, rootDir, and especially strict: true).
  • Modular Architecture: You organized your API into logical components: models, data, controllers, and routes.
  • Type-Safe Models: You defined User interfaces and DTOs (CreateUserDTO, UpdateUserDTO) to ensure data consistency and enable early error detection.
  • Express.js Fundamentals: You learned how to create an Express app, define routes, and use middleware (express.json()).
  • Robust Controllers: You implemented CRUD (Create, Read, Update, Delete) operations, leveraging TypeScript’s Request and Response generic types for strong type checking of request parameters, body, and response payloads.
  • Practical Application: You built a fully functional API and tested it with curl commands.
  • Problem Solving: You tackled a mini-challenge, extending the API, and learned how to troubleshoot common issues.

This project is a significant milestone! You’ve moved beyond theoretical concepts to practical, production-ready development patterns. You now have a solid foundation for building complex, maintainable backend services with TypeScript.

What’s Next?

In the next chapter, we’ll take this API to the next level by replacing our simple in-memory array with a real database. We’ll explore how to integrate MongoDB (a popular NoSQL database) with our TypeScript Express application, introducing concepts like Object-Document Mapping (ODM) with Mongoose and advanced error handling strategies. Get ready to connect your API to persistent storage!