Building a REST API with Express and TypeScript
Why TypeScript for APIs?
Building APIs with Express is great, but add TypeScript and it gets even better. You get type safety, better IDE support, and catch errors before they hit production. Let's build a fully typed REST API with proper error handling and validation.
Interactive Demo
Before we dive in, try out this interactive demo. It simulates the API we'll build, letting you test different HTTP methods and see how they work:
API Tester
Try different HTTP methods and endpoints to see how a REST API works. This is a simulated API for demonstration.
Setting Up the Project
First, let's create a new project and install our dependencies:
mkdir express-ts-api
cd express-ts-api
npm init -y
npm install express zod
npm install --save-dev typescript @types/express @types/node ts-node-dev
Configure TypeScript by creating a tsconfig.json
:
{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Project Structure
Let's organize our code properly:
src/
├── types/ # Type definitions
├── middleware/ # Custom middleware
├── routes/ # Route handlers
├── validators/ # Request validation
└── index.ts # Main app file
Type Definitions
Create src/types/user.ts
to define our data types:
export interface User {
id: number;
name: string;
email: string;
}
export interface CreateUserDto {
name: string;
email: string;
}
export interface UpdateUserDto {
name?: string;
email?: string;
}
Request Validation
We'll use Zod for request validation. Create src/validators/user.ts
:
import { z } from 'zod';
export const createUserSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
});
export const updateUserSchema = createUserSchema.partial();
// Type inference
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
Error Handling Middleware
Create src/middleware/error.ts
to handle different types of errors:
import { ErrorRequestHandler } from 'express';
import { ZodError } from 'zod';
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
if (err instanceof ZodError) {
return res.status(400).json({
error: 'Validation error',
details: err.errors,
});
}
console.error(err);
res.status(500).json({ error: 'Internal server error' });
};
Route Handlers
Create src/routes/users.ts
for our user routes:
import { Router } from 'express';
import { createUserSchema, updateUserSchema } from '../validators/user';
import { User } from '../types/user';
const router = Router();
// In-memory database
let users: User[] = [];
let nextId = 1;
router.get('/', (req, res) => {
res.json(users);
});
router.get('/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
router.post('/', async (req, res, next) => {
try {
const validatedData = createUserSchema.parse(req.body);
const newUser: User = {
id: nextId++,
...validatedData,
};
users.push(newUser);
res.status(201).json(newUser);
} catch (error) {
next(error);
}
});
router.put('/:id', async (req, res, next) => {
try {
const validatedData = updateUserSchema.parse(req.body);
const userId = parseInt(req.params.id);
const userIndex = users.findIndex(u => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
users[userIndex] = {
...users[userIndex],
...validatedData,
};
res.json(users[userIndex]);
} catch (error) {
next(error);
}
});
router.delete('/:id', (req, res) => {
const userId = parseInt(req.params.id);
const userIndex = users.findIndex(u => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
users = users.filter(u => u.id !== userId);
res.status(204).send();
});
export default router;
Putting It All Together
Finally, create src/index.ts
to set up our Express app:
import express from 'express';
import userRoutes from './routes/users';
import { errorHandler } from './middleware/error';
const app = express();
app.use(express.json());
app.use('/api/users', userRoutes);
app.use(errorHandler);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Testing the API
Add these scripts to your package.json
:
{
"scripts": {
"dev": "ts-node-dev --respawn src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
Now you can test your API with curl or Postman:
# Create a user
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john@example.com"}'
# Get all users
curl http://localhost:3000/api/users
# Update a user
curl -X PUT http://localhost:3000/api/users/1 \
-H "Content-Type: application/json" \
-d '{"name": "John Smith"}'
# Delete a user
curl -X DELETE http://localhost:3000/api/users/1
Best Practices
1. Error Handling
- Use custom error classes for different types of errors
- Always validate input data
- Return consistent error responses
- Log errors properly
2. Type Safety
- Use TypeScript's strict mode
- Define interfaces for all data structures
- Use type inference where possible
- Validate runtime data against your types
3. API Design
- Use proper HTTP methods and status codes
- Version your API
- Use meaningful route names
- Document your API
Next Steps
To make this API production-ready, consider adding:
- Authentication and authorization
- Rate limiting
- Request logging
- Database integration
- API documentation (e.g., with Swagger)
- Environment configuration
- Tests
Pro Tip: Consider using a dependency injection container like tsyringe
for better testability and maintainability in larger applications.