Express with TypeScript

By Syncfusion®, Inc. · Last updated

Setting up tsx and tsc, catching bugs with tsc --noEmit, typing params, bodies and responses, extending Request with req.user, runtime type guards and Zod, and building for production.

What you will learn

  • Set up an Express project with TypeScript
  • Find bugs with tsc --noEmit
  • Type params, bodies and responses
  • Add req.user with declare global
  • Validate input at runtime with type guards

Express with TypeScript

Many Express teams write their servers in TypeScript. It catches mistakes before the code runs.

  • TypeScript = JavaScript plus types. A type says what shape a value has.
  • tsc = the TypeScript compiler. It checks types and turns .ts into .js.
  • tsx = a tool that runs .ts files directly, great for development.
  • @types/express = ready-made types for Express, written by the community.
  • Types exist only while you write code. At runtime, it is all plain JavaScript.

Simple lesson: TypeScript checks your code before it runs. It cannot check what users send.

A Real-World Example

Think about the canteen order form.

  1. The form has fixed boxes: name, item, quantity. You cannot write a poem in the quantity box.
  2. The office checks the form before passing it on. Mistakes are caught early.
  3. But a student can still write "100" plates. Someone must check the real value too.

The fixed boxes are types. The office check is tsc. Checking the real value is runtime validation.

In this lesson, you will learn:

  1. How to set up an Express project with TypeScript.
  2. How tsc --noEmit catches mistakes.
  3. How to type route params, bodies and responses.
  4. How to add your own fields, such as req.user, to the Request type.
  5. Why you still validate input at runtime.
  6. How to build for production.

Every lab in this course has a TypeScript version. Choose TypeScript in the Language menu in the top bar to see it in the other lessons. Fields that the app adds, such as req.user, live in a types.d.ts file there.

Setting Up

npm install express
npm install -D typescript tsx @types/express @types/node
npx tsc --init

A small tsconfig.json for Express on Node.js 22:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist",
    "rootDir": "src",
    "skipLibCheck": true
  },
  "include": ["src"]
}
  • strict: true turns on the most useful checks. Always use it.
  • With NodeNext, imports still end in .js, even though the file is .ts.
  • npx tsx watch src/server.ts runs the app and restarts on changes.
  • import type brings in only the type. It disappears from the JavaScript.
  • req.params.id is always a string. TypeScript knows this, so you must convert it.

Simple lesson: Describe your data with interface, and let TypeScript check every use.

Catching Mistakes with tsc

tsx runs code without checking types. Run tsc --noEmit to check.

  • --noEmit = only check, do not write .js files.
  • Add it as "typecheck": "tsc --noEmit" in package.json, and run it in CI before every deploy.
  • Errors show the file, line, column and an error code like TS2551.
  • item.prise is a typo for price. In plain JavaScript, this gives NaN at runtime with no error.
  • m.id === req.params.id compares a number with a string. It would never match, so every lookup would fail.

The editors in these labs also understand TypeScript. Hover over req or item to see its type, and look for red underlines.

Simple lesson: Run tsc --noEmit before every commit. It finds bugs you would only see later.

Typing Params, Bodies and Responses

Request and Response take type arguments.

  • Request<Params, ResBody, ReqBody, Query> = the shape of each part.
  • Response<Body> = what res.json() is allowed to send.
  • Use RequestHandler or a typed function for the whole handler.
  • Without types, req.body is any. TypeScript then checks nothing.
  • Omit<Order, "id"> makes a new type: an order without the id. No need to write it twice.
  • If a handler tries res.json({ total: 5 }), tsc reports that it is not an Order.

Simple lesson: Give Request and Response your types, so every route is checked.

Adding req.user and Other Fields

Middleware often adds fields, such as req.user after login. TypeScript does not know about them.

  • Add them to the global Express.Request interface with declare global.
  • Put this in a file such as src/express.d.ts. Every route then sees the new field.
  • Mark it optional (user?:), because routes without login do not have it.
  • No output means no errors. Try changing "staff" to "teacher" in server.ts, then run again.

Simple lesson: Teach TypeScript about fields your middleware adds with declare global.

Types Do Not Check Users

Types vanish when the code runs. A user can send anything.

  • req.body as NewOrder only tells TypeScript to trust you. It checks nothing.
  • Always validate at runtime. See Input Processing and Validation.
  • A type guard is a function that checks a value and tells TypeScript its type.
  • Libraries such as Zod do both: check the value and create the type from one schema.
import { z } from "zod";

const NewOrder = z.object({ itemId: z.number().int(), quantity: z.number().int().min(1).max(10) });
type NewOrder = z.infer<typeof NewOrder>; // the type comes from the schema

const result = NewOrder.safeParse(req.body);
if (!result.success) return res.status(400).json(result.error.flatten());

as is not a check. req.body as NewOrder is a promise you make to TypeScript, and users do not keep it.

Simple lesson: Types check your code. Validation checks your users. You need both.

Building for Production

  • Development: tsx watch src/server.ts.
  • Production: compile once with tsc, then run plain JavaScript with node.
  • Do not run tsx in production. Compiling at startup is slower and hides type errors.
{
  "scripts": {
    "dev": "tsx watch --env-file-if-exists=.env src/server.ts",
    "typecheck": "tsc --noEmit",
    "build": "tsc",
    "start": "node dist/server.js",
    "test": "node --test --import tsx"
  }
}
  • In Docker, run npm run build in the image, then CMD ["node", "dist/server.js"]. See Deploying an Express App.
  • Node.js 22.18+ and 23.6+ can also run simple .ts files directly by removing the types. tsc is still needed for checking.

Easy way to remember: tsx to run, tsc to check, tsc to build, node to start.

Simple lesson: Check and compile with tsc. Ship plain JavaScript.

Common mistakes

MistakeWrong ideaCorrect ideaWhat goes wrong
Trusting req.body as Typeas checks the dataIt only silences TypeScriptBad input reaches your database
Never running tsctsx checks typestsx only removes themType errors reach production
req.params.id used as a numberParams have the right typeParams are always strings"2" === 2 is false, routes return 404
Importing ./types with no extensionTypeScript fixes importsWith NodeNext, write ./types.js"Cannot find module" at runtime
Using any everywhereIt is quickerUse real types or unknownTypeScript stops checking
Adding req.user with no typeTypeScript will guessExtend Express.Request with declare global"Property user does not exist"
Running tsx in productionIt is the same as nodeBuild with tsc, start with nodeSlower starts and hidden errors

Check Yourself

Exercises

What to Remember

  • TypeScript adds types to JavaScript. Install typescript, tsx, @types/express and @types/node.
  • tsx runs .ts files. tsc --noEmit checks them. Run the check in CI.
  • Type routes with Request<Params, ResBody, ReqBody> and Response<Body>.
  • Add fields like req.user by extending Express.Request with declare global.
  • Types vanish at runtime. Validate input with type guards or Zod.
  • Production: tsc builds to dist, node dist/server.js starts it.

Next: Capstone: The Complete Canteen API. You will put every part of the course together in one project.