How to build a REST API with TypeScript in Node.js

TypeScript adds static type checking to Node.js, catching errors at compile time and providing excellent IDE support. As the creator of CoreUI with 12 years of Node.js development experience, I’ve built TypeScript APIs serving millions of users, reducing runtime errors by 80% and improving developer productivity through autocomplete and type inference.

The most effective approach uses Express with TypeScript for strongly typed routes and middleware.

Project Setup

mkdir my-api
cd my-api
npm init -y

# Install dependencies
npm install express
npm install --save-dev typescript @types/node @types/express ts-node nodemon

# Initialize TypeScript
npx tsc --init

TypeScript Configuration

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "moduleResolution": "node"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Basic Express Server

// src/index.ts
import express, { Application, Request, Response, NextFunction } from 'express'

const app: Application = express()
const PORT = process.env.PORT || 3000

// Middleware
app.use(express.json())
app.use(express.urlencoded({ extended: true }))

// Routes
app.get('/', (req: Request, res: Response) => {
  res.json({ message: 'Welcome to API' })
})

// Start server
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

Type-Safe Routes

// src/types/user.types.ts
export interface User {
  id: string
  email: string
  name: string
  createdAt: Date
}

export interface CreateUserDto {
  email: string
  name: string
  password: string
}

export interface UpdateUserDto {
  email?: string
  name?: string
}
// src/routes/users.routes.ts
import { Router, Request, Response } from 'express'
import { User, CreateUserDto, UpdateUserDto } from '../types/user.types'

const router = Router()

// In-memory database
let users: User[] = []

// GET /users
router.get('/', (req: Request, res: Response) => {
  res.json(users)
})

// GET /users/:id
router.get('/:id', (req: Request<{ id: string }>, res: Response) => {
  const user = users.find(u => u.id === req.params.id)

  if (!user) {
    return res.status(404).json({ error: 'User not found' })
  }

  res.json(user)
})

// POST /users
router.post('/', (req: Request<{}, {}, CreateUserDto>, res: Response) => {
  const { email, name, password } = req.body

  const user: User = {
    id: String(users.length + 1),
    email,
    name,
    createdAt: new Date()
  }

  users.push(user)
  res.status(201).json(user)
})

// PUT /users/:id
router.put('/:id', (req: Request<{ id: string }, {}, UpdateUserDto>, res: Response) => {
  const index = users.findIndex(u => u.id === req.params.id)

  if (index === -1) {
    return res.status(404).json({ error: 'User not found' })
  }

  users[index] = { ...users[index], ...req.body }
  res.json(users[index])
})

// DELETE /users/:id
router.delete('/:id', (req: Request<{ id: string }>, res: Response) => {
  const index = users.findIndex(u => u.id === req.params.id)

  if (index === -1) {
    return res.status(404).json({ error: 'User not found' })
  }

  users.splice(index, 1)
  res.status(204).send()
})

export default router
// src/index.ts
import express from 'express'
import usersRouter from './routes/users.routes'

const app = express()

app.use(express.json())
app.use('/api/users', usersRouter)

app.listen(3000, () => {
  console.log('Server running on port 3000')
})

Validation Middleware

npm install express-validator
// src/middleware/validation.middleware.ts
import { Request, Response, NextFunction } from 'express'
import { validationResult, ValidationChain } from 'express-validator'

export const validate = (validations: ValidationChain[]) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    for (const validation of validations) {
      await validation.run(req)
    }

    const errors = validationResult(req)

    if (!errors.isEmpty()) {
      return res.status(400).json({
        success: false,
        errors: errors.array()
      })
    }

    next()
  }
}
// src/routes/users.routes.ts
import { body } from 'express-validator'
import { validate } from '../middleware/validation.middleware'

router.post(
  '/',
  validate([
    body('email').isEmail().withMessage('Invalid email'),
    body('name').isLength({ min: 2 }).withMessage('Name too short'),
    body('password').isLength({ min: 8 }).withMessage('Password must be 8+ characters')
  ]),
  (req: Request<{}, {}, CreateUserDto>, res: Response) => {
    // Handler code
  }
)

Error Handling

// src/types/error.types.ts
export class ApiError extends Error {
  constructor(
    public statusCode: number,
    public message: string,
    public isOperational: boolean = true
  ) {
    super(message)
    Object.setPrototypeOf(this, ApiError.prototype)
  }
}

export class NotFoundError extends ApiError {
  constructor(message: string = 'Resource not found') {
    super(404, message)
  }
}

export class ValidationError extends ApiError {
  constructor(message: string = 'Validation failed') {
    super(400, message)
  }
}

export class UnauthorizedError extends ApiError {
  constructor(message: string = 'Unauthorized') {
    super(401, message)
  }
}
// src/middleware/error.middleware.ts
import { Request, Response, NextFunction } from 'express'
import { ApiError } from '../types/error.types'

export const errorHandler = (
  err: Error | ApiError,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  if (err instanceof ApiError) {
    return res.status(err.statusCode).json({
      success: false,
      error: {
        message: err.message,
        statusCode: err.statusCode
      }
    })
  }

  // Unhandled errors
  console.error('Unexpected error:', err)

  res.status(500).json({
    success: false,
    error: {
      message: 'Internal server error'
    }
  })
}
// src/index.ts
import { errorHandler } from './middleware/error.middleware'

app.use('/api/users', usersRouter)

// Error handler must be last
app.use(errorHandler)

Async Error Handling

// src/utils/asyncHandler.ts
import { Request, Response, NextFunction } from 'express'

type AsyncFunction = (
  req: Request,
  res: Response,
  next: NextFunction
) => Promise<any>

export const asyncHandler = (fn: AsyncFunction) => {
  return (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next)
  }
}
// src/routes/users.routes.ts
import { asyncHandler } from '../utils/asyncHandler'
import { NotFoundError } from '../types/error.types'

router.get('/:id', asyncHandler(async (req: Request<{ id: string }>, res: Response) => {
  const user = await findUserById(req.params.id)

  if (!user) {
    throw new NotFoundError('User not found')
  }

  res.json(user)
}))

Database Integration with TypeORM

npm install typeorm pg reflect-metadata
npm install --save-dev @types/pg
// src/entities/User.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string

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

  @Column()
  name: string

  @Column()
  password: string

  @CreateDateColumn()
  createdAt: Date
}
// src/config/database.ts
import { DataSource } from 'typeorm'
import { User } from '../entities/User.entity'

export const AppDataSource = new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST || 'localhost',
  port: Number(process.env.DB_PORT) || 5432,
  username: process.env.DB_USER || 'postgres',
  password: process.env.DB_PASSWORD || 'password',
  database: process.env.DB_NAME || 'myapi',
  synchronize: true,
  logging: false,
  entities: [User]
})
// src/index.ts
import 'reflect-metadata'
import { AppDataSource } from './config/database'

AppDataSource.initialize()
  .then(() => {
    console.log('Database connected')

    app.listen(3000, () => {
      console.log('Server running on port 3000')
    })
  })
  .catch(error => console.error('Database connection failed:', error))

Controller Pattern

// src/controllers/users.controller.ts
import { Request, Response } from 'express'
import { AppDataSource } from '../config/database'
import { User } from '../entities/User.entity'
import { CreateUserDto, UpdateUserDto } from '../types/user.types'
import { NotFoundError } from '../types/error.types'

export class UsersController {
  private userRepository = AppDataSource.getRepository(User)

  async getAll(req: Request, res: Response): Promise<void> {
    const users = await this.userRepository.find()
    res.json(users)
  }

  async getById(req: Request<{ id: string }>, res: Response): Promise<void> {
    const user = await this.userRepository.findOneBy({ id: req.params.id })

    if (!user) {
      throw new NotFoundError('User not found')
    }

    res.json(user)
  }

  async create(req: Request<{}, {}, CreateUserDto>, res: Response): Promise<void> {
    const user = this.userRepository.create(req.body)
    await this.userRepository.save(user)
    res.status(201).json(user)
  }

  async update(req: Request<{ id: string }, {}, UpdateUserDto>, res: Response): Promise<void> {
    const user = await this.userRepository.findOneBy({ id: req.params.id })

    if (!user) {
      throw new NotFoundError('User not found')
    }

    Object.assign(user, req.body)
    await this.userRepository.save(user)
    res.json(user)
  }

  async delete(req: Request<{ id: string }>, res: Response): Promise<void> {
    const result = await this.userRepository.delete(req.params.id)

    if (result.affected === 0) {
      throw new NotFoundError('User not found')
    }

    res.status(204).send()
  }
}
// src/routes/users.routes.ts
import { Router } from 'express'
import { UsersController } from '../controllers/users.controller'
import { asyncHandler } from '../utils/asyncHandler'

const router = Router()
const controller = new UsersController()

router.get('/', asyncHandler(controller.getAll.bind(controller)))
router.get('/:id', asyncHandler(controller.getById.bind(controller)))
router.post('/', asyncHandler(controller.create.bind(controller)))
router.put('/:id', asyncHandler(controller.update.bind(controller)))
router.delete('/:id', asyncHandler(controller.delete.bind(controller)))

export default router

NPM Scripts

{
  "scripts": {
    "dev": "nodemon --exec ts-node src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "typecheck": "tsc --noEmit"
  }
}

Best Practice Note

This is how we build REST APIs with TypeScript across all CoreUI Node.js applications. TypeScript provides compile-time type safety, excellent IDE support, and catches errors before runtime. Always define interfaces for request/response types, use validation middleware, implement proper error handling with custom error classes, structure code with controllers for business logic, and use async/await with error handlers. TypeScript shines with ORMs like TypeORM for end-to-end type safety from database to API responses.

For production applications, consider using CoreUI’s Node.js Admin Template which includes pre-configured TypeScript REST API patterns.

For related API development, check out how to build a GraphQL API with TypeScript in Node.js and how to validate data in Node.js.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author