Skip to main content

TypeScript with Node.js

This chapter shows how to set up a Node.js project with TypeScript, build a small Express server, add typed middleware, connect a database with TypeORM, and organise the daily development workflow.


Why Use TypeScript with Node.js?

TypeScript adds static typing to Node.js development. That means better tooling, better code quality, and a nicer developer experience.

Key benefits:

  • Type safety for JavaScript code
  • Better IDE support with autocompletion
  • Errors are found early, while you write the code
  • Code is easier to maintain and reads like documentation
  • Refactoring is easier

Prerequisites: Install a recent Node.js LTS (v18 or newer is recommended) and npm. Check with node -v and npm -v.


Setting Up a TypeScript Node.js Project

This section walks through creating a new Node.js project set up for TypeScript.

Note: You write TypeScript (.ts) while developing and compile it to JavaScript (.js) so Node.js can run it in production.

1. Initialize a new project. Run these commands in a terminal:

  • mkdir my-ts-node-app
  • cd my-ts-node-app
  • npm init -y
  • npm install typescript @types/node --save-dev
  • npx tsc --init

What these do: typescript adds the TypeScript compiler (tsc), @types/node provides the Node.js type definitions, and npx tsc --init creates a tsconfig.json config file.

2. Create a source folder. Keep source code in src/ and compiled output in dist/. Run mkdir src; later you add files such as src/server.ts and src/middleware/auth.ts.

3. Configure TypeScript. Edit the generated tsconfig.json so compilerOptions contains:

  • "target": "ES2020"
  • "module": "commonjs"
  • "outDir": "./dist"
  • "rootDir": "./src"
  • "strict": true
  • "esModuleInterop": true
  • "skipLibCheck": true
  • "forceConsistentCasingInFileNames": true
  • "moduleResolution": "node"
  • "resolveJsonModule": true
  • "sourceMap": true

and, next to it, "include": ["src/**/*"] and "exclude": ["node_modules"].

Option highlights: rootDir and outDir keep source (src) separate from build output (dist); strict turns on the safest type checking; esModuleInterop makes CommonJS and ES modules work together more smoothly; sourceMap generates maps for debugging the compiled code.

CommonJS vs ESM: This guide uses module: "commonjs". If you use ESM (type: "module" in package.json), set module: "nodenext" or node16 and use import/export consistently.

4. Install runtime and dev dependencies. Install Express for HTTP handling plus some helpful dev tools:

  • npm install express body-parser
  • npm install --save-dev ts-node nodemon @types/express

Warning: Use ts-node and nodemon only during development. For production, compile with tsc and run Node on the JavaScript output.

Project structure. Keep the project organised: the my-ts-node-app/ folder holds src/ (with server.ts, middleware/auth.ts, entity/User.ts and config/database.ts), the generated dist/, node_modules/, package.json and tsconfig.json.


Basic TypeScript Server Example

This example shows a minimal Express server written in TypeScript. It has a typed User model and a few routes.

What TypeScript adds here:

  • Typed Request, Response and NextFunction for the Express handlers.
  • A User interface that guarantees the shape of user data.
  • Safer refactoring and better autocompletion for route params and request bodies.

Minimal Express server

An in-memory user list served over three typed routes, with an error-handling middleware at the end.

script.ts
import express, { Request, Response, NextFunction } from 'express';
import { json } from 'body-parser';

interface User {
  id: number;
  username: string;
  email: string;
}

// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;

// Middleware
app.use(json());

// In-memory database
const users: User[] = [
  { id: 1, username: 'user1', email: '[email protected]' },
  { id: 2, username: 'user2', email: '[email protected]' }
];

// Routes
app.get('/api/users', (req: Request, res: Response) => {
  res.json(users);
});

app.get('/api/users/:id', (req: Request, res: Response) => {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) return res.status(404).json({ message: 'User not found' });
  res.json(user);
});

app.post('/api/users', (req: Request, res: Response) => {
  const { username, email } = req.body;

  if (!username || !email) {
    return res.status(400).json({ message: 'Username and email are required' });
  }

  const newUser: User = {
    id: users.length + 1,
    username,
    email
  };

  users.push(newUser);
  res.status(201).json(newUser);
});

// Error handling middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ message: 'Something went wrong!' });
});

// Start server
app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

Using TypeScript with Express Middleware

Middleware can be strongly typed too.

You can also extend the Express types with declaration merging, so the request object can carry the authenticated user's data.

Typed authentication middleware

authenticate reads a bearer token and stores a user on the request; authorize checks the user's role.

script.ts
import { Request, Response, NextFunction } from 'express';

// Extend the Express Request type to include custom properties
declare global {
  namespace Express {
    interface Request {
      user?: { id: number; role: string };
    }
  }
}

export const authenticate = (req: Request, res: Response, next: NextFunction) => {
  const token = req.header('Authorization')?.replace('Bearer ', '');

  if (!token) {
    return res.status(401).json({ message: 'No token provided' });
  }

  try {
    // In a real app, verify the JWT token here
    const decoded = { id: 1, role: 'admin' }; // Mock decoded token
    req.user = decoded;
    next();
  } catch (error) {
    res.status(401).json({ message: 'Invalid token' });
  }
};

export const authorize = (roles: string[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ message: 'Not authenticated' });
    }

    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ message: 'Not authorized' });
    }

    next();
  };
};

Using the middleware in a route

In src/server.ts, the middleware functions are chained before the admin handler.

script.ts
// src/server.ts
import { authenticate, authorize } from './middleware/auth';

app.get('/api/admin', authenticate, authorize(['admin']), (req, res) => {
  res.json({ message: `Hello admin ${req.user?.id}` });
});

TypeScript with Database (TypeORM Example)

You can use an ORM such as TypeORM, which uses TypeScript decorators to map classes to database tables.

Before you start:

  • Install the packages: npm install typeorm reflect-metadata pg (use pg for PostgreSQL).
  • Because decorators are used, enable "experimentalDecorators": true and "emitDecoratorMetadata": true in the compilerOptions of tsconfig.json.
  • Import reflect-metadata once when the app starts.

User entity with decorators

Each decorator tells TypeORM how a class property maps to a column of the users table.

script.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  username: string;

  @Column({ unique: true })
  email: string;

  @Column({ select: false })
  password: string;

  @Column({ default: 'user' })
  role: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

Data source configuration

The DataSource reads its connection settings from environment variables, with local defaults.

script.ts
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { User } from '../entity/User';

export const AppDataSource = new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT || '5432'),
  username: process.env.DB_USERNAME || 'postgres',
  password: process.env.DB_PASSWORD || 'postgres',
  database: process.env.DB_NAME || 'mydb',
  synchronize: process.env.NODE_ENV !== 'production',
  logging: false,
  entities: [User],
  migrations: [],
  subscribers: [],
});

Starting the server after the database connects

In src/server.ts, the server only starts listening once the data source is initialised.

script.ts
// src/server.ts
import { AppDataSource } from './config/database';

AppDataSource.initialize()
  .then(() => {
   app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));
  })
  .catch((err) => {
   console.error('DB init error', err);
   process.exit(1);
  });

Development Workflow

1. Add scripts to package.json. Put these under "scripts":

  • "build": "tsc"
  • "start": "node dist/server.js"
  • "dev": "nodemon --exec ts-node src/server.ts"
  • "watch": "tsc -w"
  • "test": "jest --config jest.config.js"

Note: The test script is optional and assumes Jest is set up. If you are not using Jest, leave it out.

2. Run in development mode with npm run dev.

3. Build for production with npm run build, then start it with npm start.

Debugging with source maps. With sourceMap enabled in tsconfig.json, you can debug the compiled code and still see your .ts files: run node --enable-source-maps dist/server.js.

Tip: Most IDEs, including VS Code, support TypeScript debugging with breakpoints when source maps are enabled.


Best Practices

  • Always give function parameters and return values a type
  • Use interfaces for object shapes
  • Enable strict mode in tsconfig.json
  • Use type guards for runtime type checks
  • Use TypeScript's utility types (Partial, Pick, Omit, and so on)
  • Keep shared type definitions in .d.ts files
  • Use enums or const assertions for fixed sets of values
  • Document complex types with JSDoc comments
  • Prefer environment variables for secrets and config, and validate them at startup
  • Use ts-node/nodemon only in development; compile for production
  • Consider ESLint + Prettier with @typescript-eslint for consistent code quality