Updated Nov 5, 20266 min read

How to Set Up Express With TypeScript

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.

Express with TypeScript vs Encore at a glance

EncoreExpress + TypeScript
Request validationFrom the TypeScript types, at runtimeSeparate schema (Zod, Joi) per route
Typed handlersRequest and response interfaces per endpointRequest<Params, ResBody, ReqBody, Query> generics
Run in developmentencore runtsx watch or node --watch
API docs and clientsGenerated from the typesAdd OpenAPI tooling separately
Databases and queuesDeclared in code, started locally by encore runSeparate libraries and local containers
Deploy targetYour own AWS or GCP accountAny Node.js host

Each option is covered below, starting with Encore.

Using TypeScript types as the API contract 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 );
Get started
brew install encoredev/tap/encore &&
encore app create
Copy

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.

Step 1: install Express and TypeScript

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".

Step 2: write typed routes

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.

Step 3: build and run in production

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.

Good to know

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.

How to choose

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.

Frequently asked questions

How do I use TypeScript with Express?

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.

Does TypeScript validate Express request bodies?

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.

How do I get runtime validation from TypeScript types with Encore?

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders