Your Node.js backend works, but every time you add a route or modify a model, you end up hunting for errors that could have been avoided. A field typed wrong, a wrong type, an API response that changes shape without warning. If you're reading this guide, you probably know that feeling.
We, at Meteora Web, work with Laravel and TypeScript every day. And we know that the difference between a backend that holds up and one that breaks is almost always typing. It's not a matter of fashion: it's a matter of costs. Every bug that reaches production is wasted time, unhappy customers, and lower revenue.
In this guide, we'll show you how to build a backend with TypeScript, Node.js, and Express that doesn't break. We start with the concepts, then move to actionable code. By the end, you'll have a solid foundation you can use right away in your projects.
Why is TypeScript with Node.js and Express the right choice for your backend?
When you work with plain JavaScript, the runtime doesn't warn you if you pass a string where a number is needed. The code starts, maybe works for months, then an unexpected piece of data breaks everything. TypeScript adds a static control layer that catches these errors before they reach production.
Think of it this way: JavaScript is like driving without a dashboard. You can go, but you never know if you're running out of fuel. TypeScript is the dashboard that tells you exactly what's happening, before the engine dies.
With Express, the advantage is double. Express is minimalist and flexible, but precisely because of that, it leaves room for type errors. TypeScript makes it predictable, without taking away its power.
Common errors TypeScript prevents
- Passing an id as a string where the database expects a number
- Forgetting a field in an API response, causing client-side errors
- Modifying a function and breaking all the calls that use it
These aren't theoretical scenarios. We see them every day in the projects that come to us. And every time, the solution is the same: type first, debug later.
Sponsored Protocol
How to set up a Node.js project with TypeScript and Express?
Let's start from scratch. If you already have a project, you can skip to the next paragraph. But if you're starting, this is the foundation you need.
Step 1: Initialize the project
mkdir backend-ts && cd backend-ts
npm init -y
npm install express
npm install -D typescript @types/node @types/express ts-node nodemonHere we install Express for the server, and TypeScript with types for Node and Express as dev dependencies. ts-node lets us run TypeScript directly, while nodemon restarts the server on every change.
Step 2: Configure TypeScript
Create a tsconfig.json file in the project root:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}The strict: true option is fundamental. It enables all the strictest type checks, which is exactly what we want. Without strict, TypeScript loses much of its value.
Step 3: Create the base server
Now let's create a src/index.ts file with a minimal Express server:
import express, { Request, Response } from 'express';
const app = express();
const port = 3000;
app.get('/', (req: Request, res: Response) => {
res.send('TypeScript backend working');
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});Notice how we typed req and res. This is the first step: every handler must have explicit types. Don't let TypeScript infer them, because in Express, inference is often not enough.
To start the server in development, add to package.json:
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}Now you can run npm run dev and see your server in action.
How to type requests and responses in Express with TypeScript?
The real power of TypeScript emerges when you define types for the data that goes in and out of your server. A route that accepts an id and returns a user must have clear types for both.
Sponsored Protocol
Define a shared model
Let's create a src/types.ts file with the types we'll use across the backend:
export interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}
export interface CreateUserRequest {
name: string;
email: string;
}These types are the source of truth. When the database returns a user, we know exactly what shape it has. When we receive a request to create a user, we know which fields to expect.
Type the request parameters
import { Request, Response } from 'express';
import { User, CreateUserRequest } from './types';
app.get('/users/:id', (req: Request<{ id: string }>, res: Response) => {
const userId = parseInt(req.params.id, 10);
// Simulate a database lookup
const user: User = {
id: userId,
name: 'John Doe',
email: 'john@example.com',
createdAt: new Date()
};
res.json(user);
});
app.post('/users', (req: Request<{}, {}, CreateUserRequest>, res: Response) => {
const { name, email } = req.body;
// Here we would create the user in the database
const newUser: User = {
id: Date.now(),
name,
email,
createdAt: new Date()
};
res.status(201).json(newUser);
}); With Request<{ id: string }>, we tell TypeScript that the id parameter is a string. With Request<{}, {}, CreateUserRequest>, we say the body must have the shape of CreateUserRequest. If someone tries to send an extra or missing field, TypeScript flags it at development time.
This approach eliminates an entire class of bugs. You no longer have to remember the shape of data by heart: the compiler does it for you.
How to handle errors in a typed way with Express and TypeScript?
Errors are inevitable. But the difference between a professional backend and a makeshift one lies in how you handle them. With TypeScript, you can create an error system that is predictable and typed.
Sponsored Protocol
Create a custom error class
export class AppError extends Error {
statusCode: number;
details?: unknown;
constructor(statusCode: number, message: string, details?: unknown) {
super(message);
this.statusCode = statusCode;
this.details = details;
}
}Now every error we throw has an HTTP status code and a clear message. In routes, we can throw specific errors:
app.get('/users/:id', (req: Request<{ id: string }>, res: Response) => {
const userId = parseInt(req.params.id, 10);
const user = findUserById(userId);
if (!user) {
throw new AppError(404, 'User not found');
}
res.json(user);
});Error handling middleware
In Express, we add a final middleware that catches all errors and responds uniformly:
import { NextFunction, Request, Response } from 'express';
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: err.message,
details: err.details
});
return;
}
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});With instanceof AppError, we distinguish errors we know from unexpected ones. The client always receives a JSON response with a clear structure, never a silent crash.
This middleware must be added after all routes. This way, Express uses it as a last resort for any unhandled error.
How to integrate TypeScript with a database in an Express backend?
The database is the heart of the backend. And typing queries is the best way to avoid costly errors. With an ORM like Prisma or TypeORM, TypeScript really shines.
Example with Prisma
Prisma generates types automatically from your schema. If you have a User model, you get a User type with all fields. Here's how to use it in a route:
import { PrismaClient } from '@prisma/client';
import { Request, Response } from 'express';
const prisma = new PrismaClient();
app.get('/users/:id', async (req: Request<{ id: string }>, res: Response) => {
const userId = parseInt(req.params.id, 10);
try {
const user = await prisma.user.findUnique({
where: { id: userId }
});
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Database error' });
}
});The type of user is inferred automatically by Prisma. If the model changes, TypeScript warns you in every part of the code that uses that type. Zero surprises.
Sponsored Protocol
If you prefer a more pure SQL approach, you can use a query builder like Kysely, which is typed but doesn't hide SQL from you. The choice depends on your project, but the principle is the same: data must have a known shape.
What are the best practices for a TypeScript backend with Express in production?
We've covered the basics. Now let's move to what distinguishes a backend that holds up from one that breaks. These are the rules we apply at Meteora Web in every project.
1. Use DTOs for incoming data
Never pass the request body directly to the database. Create a Data Transfer Object that defines exactly which fields are accepted. This prevents mass assignment attacks and keeps the code clean.
// dto/create-user.dto.ts
export interface CreateUserDTO {
name: string;
email: string;
password: string;
}2. Validate incoming data
TypeScript checks types at compile-time, but at runtime the body arrives as unvalidated JSON. Use a library like zod to validate data before using it.
import { z } from 'zod';
const createUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8)
});
app.post('/users', (req: Request, res: Response) => {
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
res.status(400).json({ error: result.error.errors });
return;
}
// result.data is typed and validated
res.status(201).json(result.data);
});Zod integrates perfectly with TypeScript. You can also infer types directly from the schema, avoiding duplication.
Sponsored Protocol
3. Don't use any
The temptation to use any is strong, especially when working with untyped libraries. But any disables all checks and brings you back to the original problem. If a library doesn't have types, look for a typed wrapper or define the types yourself.
4. Separate routes from controllers
Don't write all the logic in route functions. Separate routes (which handle the request) from controllers (which contain the business logic). This makes the code testable and maintainable.
// routes/user.routes.ts
import { Router } from 'express';
import { getUsers, createUser } from '../controllers/user.controller';
const router = Router();
router.get('/', getUsers);
router.post('/', createUser);
export default router;What to do now for a TypeScript backend that doesn't break?
You have the tools, now put them into practice. You don't need to redo everything from scratch: start with the project you already have and apply these changes one at a time.
- Configure TypeScript with strict: true and fix all the errors that emerge. It'll be tedious, but it's the first step.
- Type existing routes: add types to Request and Response in every handler. Then define interfaces for the main data.
- Add an error handling middleware with a custom AppError class. Standardize error responses.
- Introduce a validation system like zod for incoming data. Never trust the request body.
- Separate routes and controllers if you haven't already. The code becomes more readable and testable.
We, at Meteora Web, know that typing isn't a luxury: it's a necessity. A typed backend costs less to maintain, has fewer bugs, and lets you sleep at night. If you want to go deeper, start with our complete TypeScript guide where you'll find all the connected concepts.
And if you have a project that keeps breaking, talk to us. We'll help you bring order.