A working Express 5 and TypeScript setup: tsconfig, typed request handlers, runtime validation, error handling and a production build, plus where typed Express stops at compile time.
Express is still the most common way to write a Node.js API, and adding TypeScript to it is mostly a matter of a tsconfig.json, the @types/express package and a build step. Express 5 also forwards rejected promises from async handlers to the error middleware, which removes the oldest pain point of async Express code.
The types stop at compile time, since TypeScript erases them from the JavaScript it emits. A handler typed with Request<{}, {}, CreateUser> receives whatever JSON the client sent, so every endpoint needs a runtime schema that repeats the interface. This guide sets up Express with TypeScript, including that validation, and then shows the same endpoint in Encore (a backend framework with built-in infrastructure) where the type is the validation.
Each option is covered below, starting with Encore.
Encore is a backend framework with built-in infrastructure for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. Endpoints are plain async functions wrapped in api, and the request and response interfaces define both the compile-time types and the runtime validation: a request missing a required field, or sending a string where a number belongs, gets a 400 before the handler runs.
// users/users.ts
import { api, APIError } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
const db = new SQLDatabase("users", { migrations: "./migrations" });
interface CreateUser {
email: string;
name: string;
plan: "free" | "team";
}
interface User {
id: number;
email: string;
name: string;
}
export const create = api(
{ expose: true, method: "POST", path: "/users" },
async (req: CreateUser): Promise<User> => {
const user = await db.queryRow<User>`
INSERT INTO users (email, name, plan)
VALUES (${req.email}, ${req.name}, ${req.plan})
RETURNING id, email, name`;
if (!user) throw APIError.internal("insert failed");
return user;
},
);
The migration in users/migrations creates the users table the queries use:
-- users/migrations/1_create_users.up.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
plan TEXT NOT NULL
);
Run in your terminal to get started locally.
encore run starts the API and Postgres locally and applies the migrations in ./migrations, and the local dashboard has API docs and an API Explorer built from the same types. encore gen client produces a typed TypeScript client or an OpenAPI spec for the frontend, so the contract is written once. With the types, the database and the endpoint in one file, an AI coding agent can add a field to CreateUser, add the column in a migration and test the endpoint locally in the same change, and an invalid request fails the same way for the agent as for a user. Encore also has raw endpoints that hand you the Node.js request and response for webhooks or streaming, and the Express comparison covers routing and middleware in more depth.
Create a project and install Express with the type packages and tsx for running TypeScript directly in development:
mkdir express-ts && cd express-ts
npm init -y
npm install express zod
npm install -D typescript tsx @types/express @types/node
npx tsc --init
In tsconfig.json, set "module": "nodenext", "target": "es2022", "outDir": "dist", "rootDir": "src" and "strict": true, and add "type": "module" to package.json so the compiled output uses ES modules. Then add scripts: "dev": "tsx watch src/index.ts", "build": "tsc" and "start": "node dist/index.js".
Request and Response from Express take generics for route params, response body, request body and query, which gives you autocompletion and catches typos in handlers:
// src/index.ts
import express, { type Request, type Response, type NextFunction } from "express";
import { z } from "zod";
const app = express();
app.use(express.json());
const CreateUser = z.object({
email: z.string().email(),
name: z.string().min(1),
plan: z.enum(["free", "team"]),
});
type CreateUser = z.infer<typeof CreateUser>;
interface User {
id: number;
email: string;
name: string;
}
app.post(
"/users",
async (req: Request<{}, User | { error: unknown }, CreateUser>, res: Response) => {
const parsed = CreateUser.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const user = await saveUser(parsed.data); // your database call
res.status(201).json(user);
},
);
// Express 5 forwards rejected promises from async handlers here.
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
res.status(500).json({ error: "internal error" });
});
app.listen(Number(process.env.PORT ?? 3000));
Without the safeParse call, the CreateUser generic is only a promise the compiler trusts, and Zod gives you the runtime check plus an inferred type so the schema and interface cannot drift apart.
Run npm run build to compile to dist/, then npm start. In a container, build in one stage and copy dist/ and production dependencies into a slim Node.js image that runs as the non-root node user. The Express production best practices guide covers setting NODE_ENV=production, compression at the proxy and running behind a process manager or orchestrator.
Express 5 is the current major version, and its migration guide lists the breaking changes, including path syntax changes for wildcards and optional parameters. @types/express is maintained by the community rather than the Express team, so the types can lag behind new features. Middleware that attaches properties such as req.user needs a declaration merge on the Express.Request interface, otherwise TypeScript will not know the property exists.
Express with TypeScript is a good fit when you want the largest middleware ecosystem and full control over every layer, and you are fine maintaining a schema alongside each interface. Add Zod or a similar validator from the first route, since the types alone do not protect the API.
Pick Encore when you want one set of TypeScript types to drive validation, API docs and clients, with the database and queues the API uses started locally by encore run and deployed to your own AWS or GCP account.
Install express, typescript, @types/express and @types/node, create a tsconfig.json, and write your app in .ts files. Run it in development with tsx or node --watch and compile with tsc for production, typing handlers with Request and Response generics for params, body and response.
TypeScript does not validate them, since types are erased at compile time, so typing req.body as an interface does not check what the client actually sent. You need a runtime validator such as Zod in each handler or a middleware to reject invalid input.
Define the endpoint with api from encore.dev/api and give it request and response interfaces. Encore validates incoming requests against those TypeScript types at runtime and returns 400 Bad Request when a required field is missing or has the wrong type, with no separate schema.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.