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:
- 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
Userobject withidandname, TypeScript will ensure your backend sends that, and will warn you if you try to send something else. - 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.
- 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.
- 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.
- 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.
Create Project Directory: Open your terminal or command prompt and create a new folder for our project.
mkdir my-ts-api cd my-ts-apiInitialize Node.js Project: This command creates a
package.jsonfile, which manages our project’s metadata and dependencies. The-yflag answers “yes” to all prompts, creating a defaultpackage.json.npm init -yGo ahead and open
package.json. You’ll see basic information about your project.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 (
-Dor--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 [email protected]You can always verify the latest version on the official TypeScript GitHub: https://github.com/microsoft/TypeScript
Initialize TypeScript Configuration: We need a
tsconfig.jsonfile to tell the TypeScript compiler (tsc) how to compile our TypeScript code into JavaScript.npx tsc --initThis command creates a
tsconfig.jsonfile 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.Node16is 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 adist(distribution) folder."rootDir": "./src": Our TypeScript source files will live in thesrcfolder. 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 Moduleimportsyntax (import express from 'express';) even if they don’t explicitly have a default export. Without it, you might needimport * as express from 'express';."strict": true: Crucial for type safety! This enables a whole suite of strict type-checking options (likenoImplicitAny,noImplicitReturns,strictNullChecks). Always enable this for robust TypeScript."skipLibCheck": true: Speeds up compilation by skipping type checking of declaration files (like those fromnode_modules).
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@latestexpress@latestwill install the most recent stable version (likely4.x).@types/express@latestwill provide type definitions corresponding to that version.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@latestAdd Development Scripts to
package.json: Openpackage.jsonagain. 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.jsonmight be slightly newer than shown here due to@latestinstalls, which is perfectly fine!)Explanation of scripts:
npm run dev: This will usenodemonto watch oursrcfolder. Whenever we save a.tsfile,ts-nodewill re-executesrc/app.ts, restarting our server. Super convenient!npm run build: This will simply run the TypeScript compiler (tsc), which will compile all our.tsfiles fromsrcinto.jsfiles in thedistdirectory according to ourtsconfig.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.
Create
srcDirectory:mkdir srcCreate
src/app.ts: Inside thesrcfolder, create a new file namedapp.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 toesModuleInterop: trueintsconfig.json, we can use this clean ES Module syntax.const app = express();: We create an instance of the Express application. Thisappobject 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 useprocess.env.PORTfor deployment environments and fall back to a default (like3000) for local development.app.get('/', (req, res) => { ... });: This sets up a “route handler”.app.get(): Specifies that this handler should respond toGETrequests./: This is the path, meaning the root URL of our API.(req, res) => { ... }: This is the callback function that gets executed when aGETrequest 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 useres.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 specifiedPORT. The callback function is executed once the server successfully starts.
Run Your Server: Now, let’s see our server in action!
npm run devYou 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!
Create
src/modelsDirectory:mkdir src/modelsCreate
src/models/user.model.ts: Insidesrc/models, createuser.model.tsand 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 aninterfacenamedUser. This is a blueprint for what aUserobject must look like.id: string;: Every user must have anidproperty, and it must be astring.name: string;: Same forname.email: string;: Andemail.age?: number;: The?makesagean optional property. If it exists, it must be anumber.
export type CreateUserDTO = Omit<User, 'id'>;: Here we introduce aDTO(Data Transfer Object). When we create a user, we don’t send theidfrom the client; the server generates it.Omit<User, 'id'>is a powerful TypeScript utility type that creates a new type by taking all properties fromUserexceptid.export type UpdateUserDTO = Partial<CreateUserDTO>;: For updating a user, we might only send a few fields (e.g., just thename).Partial<CreateUserDTO>is another utility type that makes all properties ofCreateUserDTOoptional.
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!
Create
src/dataDirectory:mkdir src/dataCreate
src/data/users.ts: Insidesrc/data, createusers.tsand 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: '[email protected]', age: 30 }, { id: '2', name: 'Bob Johnson', email: '[email protected]', age: 24 }, { id: '3', name: 'Charlie Brown', email: '[email protected]' }, // Age is optional ];Code Explanation:
import { User } from '../models/user.model';: We import ourUserinterface so we can use it to type ourusersarray.export const users: User[] = [...]: We declare a constant array namedusersand explicitly type it asUser[]. This tells TypeScript that every element in this array must conform to theUserinterface. If you tried to add an object like{ name: 'Dave' }(missingidandemail), 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.
Create
src/controllersDirectory:mkdir src/controllersCreate
src/controllers/user.controller.ts: Insidesrc/controllers, createuser.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 theuuidpackage yet. Let’s do that now:npm install uuid@latest npm install -D @types/uuid@latestThis installs the
uuidlibrary 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 theRequestandResponsetypes provided by@types/express. These are incredibly powerful!req: Request<{ id: string }>, res: Response<User | { message: string }>: Look at the type annotations forreqandres!Request<{ id: string }>: This tells TypeScript that thereq.paramsobject for this specific route must have anidproperty of typestring. If you try to accessreq.params.userIdorreq.params.id.toFixed(), TypeScript will warn you!Request<{}, {}, CreateUserDTO>: ForcreateUser, the first two generic arguments areParamsandQuery(which we’re not using, hence{}), and the third isBody. This meansreq.bodymust conform to ourCreateUserDTOinterface. If a client sends a request body like{ age: 25 }withoutnameoremail, TypeScript won’t catch it at runtime, but it ensures that within our code, we treatreq.bodyas aCreateUserDTO. 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. ForgetUserById, we might send aUserobject (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 entireusersarray.getUserById: Finds a user byidfromreq.params.createUser: Takes data fromreq.body(typed asCreateUserDTO), generates a newidusinguuidv4(), creates aUserobject, and adds it to theusersarray. Includes basic validation.updateUser: Finds a user byid, takes partial updates fromreq.body(typed asUpdateUserDTO), and merges them with the existing user data.deleteUser: Removes a user from the array based onid.
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.
Create
src/routesDirectory:mkdir src/routesCreate
src/routes/user.routes.ts: Insidesrc/routes, createuser.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 routerCode Explanation:
import { Router } from 'express';: We import theRouterclass 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 aGETrequest to its base path (/), it should call thegetAllUsersfunction from our controller.router.get('/:id', getUserById);: The/:idpart is a route parameter. Express will parse the URL (e.g.,/api/users/123) and makeid: '123'available inreq.params.- We define routes for
POST,PUT,PATCH, andDELETEas well, linking them to their respective controller functions. export default router;: We export the configured router so our mainapp.tsfile 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.
Modify
src/app.ts: Opensrc/app.tsagain 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 thedefaultexport fromuser.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.bodywould beundefinedforPOSTandPUTrequests.app.use('/api/users', userRoutes);: This tells our Expressappto useuserRoutesfor any requests that start with/api/users. So, aGET /api/usersrequest will be handled bygetAllUsers, and aPOST /api/userswill be handled bycreateUser, 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:
GET all users:
curl http://localhost:3000/api/usersExpected output:
[ {"id":"1","name":"Alice Smith","email":"[email protected]","age":30}, {"id":"2","name":"Bob Johnson","email":"[email protected]","age":24}, {"id":"3","name":"Charlie Brown","email":"[email protected]"} ]GET user by ID:
curl http://localhost:3000/api/users/1Expected output:
{"id":"1","name":"Alice Smith","email":"[email protected]","age":30}Try with a non-existent ID:
curl http://localhost:3000/api/users/99Expected output:
{"message":"User not found"}POST a new user:
curl -X POST -H "Content-Type: application/json" -d '{"name": "David Lee", "email": "[email protected]", "age": 28}' http://localhost:3000/api/usersExpected output (with a new generated ID):
{"id":"<some-uuid>","name":"David Lee","email":"[email protected]","age":28}Now, if you
GETall 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/usersExpected output:
{"message":"Name and email are required."}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/1Expected output:
{"id":"1","name":"Alicia Smith-Jones","email":"[email protected]","age":31}Notice how the
emailwas not changed because we didn’t include it in thePUTbody, butnameandagewere updated.DELETE a user: Let’s delete Bob (assuming his ID is ‘2’).
curl -X DELETE http://localhost:3000/api/users/2Expected 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:
- Define a
Productinterface (andCreateProductDTO,UpdateProductDTO) insrc/models/product.model.ts. AProductmight haveid: string,name: string,price: number,description?: string. - Create an in-memory
productsarray insrc/data/products.ts. - Implement product-specific controller functions (
getAllProducts,getProductById, etc.) insrc/controllers/product.controller.ts. Remember to use proper TypeScript types forreqandres! - Set up product routes in
src/routes/product.routes.ts. - Integrate the
productRoutesintosrc/app.tsusingapp.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
Productdata, just as it did forUserdata. - 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:
“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.
- Cause: You likely forgot to install
req.bodyisundefinedforPOST/PUTrequests.- Cause: You forgot to include the
express.json()middleware insrc/app.ts. - Solution: Add
app.use(express.json());before you define your routes insrc/app.ts.
- Cause: You forgot to include the
TypeScript complains about
req.params.idnot existing or having the wrong type.- Cause: You might not have correctly typed the
Requestobject 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 thatreq.paramswill have anidproperty of typestring.
- Cause: You might not have correctly typed the
“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.
- Check your interfaces (
nodemonisn’t restarting orts-nodeisn’t working.- Cause: Misconfigured
package.jsonscripts ortsconfig.json. - Solution:
- Double-check your
devscript inpackage.json:nodemon --exec ts-node src/app.ts. - Ensure
ts-nodeandnodemonare installed as dev dependencies. - Verify
tsconfig.json’srootDirandoutDirare correct relative to yoursrcfolder.
- Double-check your
- Cause: Misconfigured
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-nodeandnodemon. - TypeScript Configuration: You configured
tsconfig.jsonwith modern settings (target,module,outDir,rootDir, and especiallystrict: true). - Modular Architecture: You organized your API into logical components: models, data, controllers, and routes.
- Type-Safe Models: You defined
Userinterfaces 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
RequestandResponsegeneric types for strong type checking of request parameters, body, and response payloads. - Practical Application: You built a fully functional API and tested it with
curlcommands. - 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!