TypeScript

TypeScript with Node.js

28 min Lesson 20 of 40

TypeScript with Node.js

TypeScript brings significant benefits to Node.js development, including better error detection, improved IDE support, and more maintainable code. In this lesson, we'll explore how to build type-safe Node.js applications with Express, covering everything from basic setup to advanced typing patterns.

Setting Up a TypeScript Node.js Project

Start by initializing a Node.js project with TypeScript configuration and necessary dependencies.

Project Setup:
<# Initialize project
mkdir my-node-app
cd my-node-app
npm init -y

# Install TypeScript and Node types
npm install --save-dev typescript @types/node

# Initialize TypeScript configuration
npx tsc --init

# Install additional dependencies
npm install express
npm install --save-dev @types/express

# Install development tools
npm install --save-dev ts-node nodemon

# Project structure
my-node-app/
├── src/
│   ├── index.ts
│   ├── routes/
│   ├── controllers/
│   ├── middleware/
│   └── types/
├── dist/           # Compiled JavaScript
├── tsconfig.json
└── package.json
>

TypeScript Configuration for Node.js

Configure tsconfig.json with settings optimized for Node.js development.

tsconfig.json:
<{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "moduleResolution": "node",
    "types": ["node"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
>
package.json Scripts:
<{
  "scripts": {
    "dev": "nodemon --exec ts-node src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "type-check": "tsc --noEmit"
  }
}
>

Typing Express Application

Create a type-safe Express application with properly typed routes, middleware, and handlers.

Basic Express Setup:
<// src/index.ts
import express, { Application } 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, res) => {
  res.json({ message: 'Hello, TypeScript!' });
});

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

Typing Request and Response

Express provides generic types for Request and Response objects, allowing you to specify custom types for params, query, body, and response data.

Request/Response Typing:
<import { Request, Response, NextFunction } from 'express';

// Type route parameters
interface UserParams {
  id: string;
}

// Type query parameters
interface UserQuery {
  includeProfile?: string;
  fields?: string;
}

// Type request body
interface CreateUserBody {
  name: string;
  email: string;
  password: string;
}

// Type response body
interface UserResponse {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
}

// Fully typed route handler
app.get(
  '/users/:id',
  (
    req: Request<UserParams, {}, {}, UserQuery>,
    res: Response<UserResponse>
  ) => {
    const userId = req.params.id; // Type: string
    const includeProfile = req.query.includeProfile; // Type: string | undefined

    // TypeScript enforces response type
    res.json({
      id: parseInt(userId),
      name: 'John Doe',
      email: 'john@example.com',
      createdAt: new Date()
    });
  }
);

// POST route with body typing
app.post(
  '/users',
  (
    req: Request<{}, UserResponse, CreateUserBody>,
    res: Response<UserResponse>
  ) => {
    const { name, email, password } = req.body;

    // TypeScript knows the types
    console.log(name.toUpperCase()); // Type-safe
    // console.log(req.body.invalid); // Error: Property doesn't exist

    res.status(201).json({
      id: 1,
      name,
      email,
      createdAt: new Date()
    });
  }
);
>
Note: Request generic signature: Request<Params, ResBody, ReqBody, ReqQuery>

Typing Middleware Functions

Middleware functions in Express can be typed for better error detection and autocomplete support.

Middleware Typing:
<import { Request, Response, NextFunction, RequestHandler } from 'express';

// Basic middleware type
const logger = (req: Request, res: Response, next: NextFunction): void => {
  console.log(`${req.method} ${req.path}`);
  next();
};

// Using RequestHandler type
const authenticate: RequestHandler = (req, res, next) => {
  const token = req.headers.authorization;

  if (!token) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // Verify token logic
  next();
};

// Middleware with custom request properties
declare global {
  namespace Express {
    interface Request {
      user?: {
        id: string;
        email: string;
        role: 'admin' | 'user';
      };
    }
  }
}

const attachUser: RequestHandler = (req, res, next) => {
  // Attach user to request
  req.user = {
    id: '123',
    email: 'user@example.com',
    role: 'user'
  };
  next();
};

// Use middleware
app.use(logger);
app.use(authenticate);
app.use(attachUser);

// Access custom properties in routes
app.get('/profile', (req, res) => {
  // TypeScript knows about req.user
  if (req.user) {
    res.json({ user: req.user });
  } else {
    res.status(401).json({ error: 'Not authenticated' });
  }
});
>
Tip: Use declaration merging to extend Express types with custom properties without modifying the original type definitions.

Typing Async Route Handlers

Async route handlers require proper error handling. Create a wrapper to handle async errors automatically.

Async Handler Wrapper:
<import { Request, Response, NextFunction, RequestHandler } from 'express';

// Type-safe async wrapper
type AsyncRequestHandler<
  P = any,
  ResBody = any,
  ReqBody = any,
  ReqQuery = any
> = (
  req: Request<P, ResBody, ReqBody, ReqQuery>,
  res: Response<ResBody>,
  next: NextFunction
) => Promise<any>;

const asyncHandler = <P, ResBody, ReqBody, ReqQuery>(
  fn: AsyncRequestHandler<P, ResBody, ReqBody, ReqQuery>
): RequestHandler<P, ResBody, ReqBody, ReqQuery> => {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};

// Usage with typed async handler
interface GetUserParams {
  id: string;
}

interface UserResponse {
  id: number;
  name: string;
  email: string;
}

app.get(
  '/users/:id',
  asyncHandler<GetUserParams, UserResponse, {}, {}>(
    async (req, res) => {
      const userId = parseInt(req.params.id);

      // Simulate database call
      const user = await fetchUserFromDB(userId);

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

      // Type-safe response
      res.json({
        id: user.id,
        name: user.name,
        email: user.email
      });
    }
  )
);

// Simulated async function
async function fetchUserFromDB(id: number): Promise<UserResponse | null> {
  // Database logic here
  return {
    id,
    name: 'John Doe',
    email: 'john@example.com'
  };
}
>

Typing Environment Variables

Environment variables are untyped by default. Create type-safe environment configuration.

Environment Variables:
<// src/types/environment.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV: 'development' | 'production' | 'test';
      PORT: string;
      DATABASE_URL: string;
      JWT_SECRET: string;
      API_KEY: string;
    }
  }
}

export {};

// src/config/env.ts
interface Config {
  env: string;
  port: number;
  database: {
    url: string;
  };
  jwt: {
    secret: string;
  };
  api: {
    key: string;
  };
}

const getConfig = (): Config => {
  // Validate required environment variables
  const requiredEnvVars = [
    'NODE_ENV',
    'PORT',
    'DATABASE_URL',
    'JWT_SECRET',
    'API_KEY'
  ];

  for (const envVar of requiredEnvVars) {
    if (!process.env[envVar]) {
      throw new Error(`Missing required environment variable: ${envVar}`);
    }
  }

  return {
    env: process.env.NODE_ENV,
    port: parseInt(process.env.PORT, 10),
    database: {
      url: process.env.DATABASE_URL
    },
    jwt: {
      secret: process.env.JWT_SECRET
    },
    api: {
      key: process.env.API_KEY
    }
  };
};

export const config = getConfig();

// Usage - fully type-safe
import { config } from './config/env';

console.log(config.port); // Type: number
console.log(config.env); // Type: string
console.log(config.database.url); // Type: string
>
Warning: Always validate environment variables at startup. Missing or invalid environment variables can cause runtime errors.

Typing Database Models

Create type-safe database models and queries for better code quality.

Database Model Types:
<// src/types/models.ts
export interface User {
  id: number;
  email: string;
  password: string;
  name: string;
  role: 'admin' | 'user';
  createdAt: Date;
  updatedAt: Date;
}

export interface Post {
  id: number;
  title: string;
  content: string;
  authorId: number;
  published: boolean;
  createdAt: Date;
  updatedAt: Date;
}

// DTO types (Data Transfer Objects)
export type CreateUserDTO = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
export type UpdateUserDTO = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
export type UserResponse = Omit<User, 'password'>;

// src/services/userService.ts
import { User, CreateUserDTO, UpdateUserDTO, UserResponse } from '../types/models';

class UserService {
  async create(data: CreateUserDTO): Promise<UserResponse> {
    // Database insert logic
    const user: User = {
      id: 1,
      ...data,
      createdAt: new Date(),
      updatedAt: new Date()
    };

    // Omit password before returning
    const { password, ...userResponse } = user;
    return userResponse;
  }

  async findById(id: number): Promise<UserResponse | null> {
    // Database query logic
    return null;
  }

  async update(id: number, data: UpdateUserDTO): Promise<UserResponse> {
    // Database update logic
    return {} as UserResponse;
  }

  async delete(id: number): Promise<void> {
    // Database delete logic
  }
}

export const userService = new UserService();
>

Error Handling with Custom Types

Create type-safe error handling with custom error classes and error middleware.

Custom Error Types:
<// src/types/errors.ts
export class AppError extends Error {
  constructor(
    public statusCode: number,
    public message: string,
    public isOperational: boolean = true
  ) {
    super(message);
    Object.setPrototypeOf(this, AppError.prototype);
  }
}

export class ValidationError extends AppError {
  constructor(message: string) {
    super(400, message);
  }
}

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

export class UnauthorizedError extends AppError {
  constructor(message: string = 'Unauthorized') {
    super(401, message);
  }
}

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../types/errors';

interface ErrorResponse {
  status: 'error';
  statusCode: number;
  message: string;
  stack?: string;
}

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response<ErrorResponse>,
  next: NextFunction
): void => {
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      status: 'error',
      statusCode: err.statusCode,
      message: err.message,
      ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
    });
    return;
  }

  // Unexpected errors
  console.error('Unexpected Error:', err);
  res.status(500).json({
    status: 'error',
    statusCode: 500,
    message: 'Internal Server Error',
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  });
};

// Usage in routes
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await userService.findById(parseInt(req.params.id));

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

  res.json(user);
}));

// Register error handler (must be last)
app.use(errorHandler);
>

Typing API Responses

Create consistent, type-safe API response structures.

Response Types:
<// src/types/responses.ts
export interface ApiResponse<T = any> {
  success: boolean;
  data?: T;
  error?: {
    message: string;
    code?: string;
  };
  meta?: {
    page?: number;
    limit?: number;
    total?: number;
  };
}

// Helper functions
export const successResponse = <T>(data: T, meta?: ApiResponse['meta']): ApiResponse<T> => ({
  success: true,
  data,
  ...(meta && { meta })
});

export const errorResponse = (message: string, code?: string): ApiResponse => ({
  success: false,
  error: { message, code }
});

// Usage in controllers
import { successResponse, errorResponse } from '../types/responses';

app.get('/users', asyncHandler(async (req, res) => {
  const users = await userService.findAll();

  res.json(successResponse(users, {
    page: 1,
    limit: 10,
    total: users.length
  }));
}));

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await userService.findById(parseInt(req.params.id));

  if (!user) {
    return res.status(404).json(errorResponse('User not found', 'USER_NOT_FOUND'));
  }

  res.json(successResponse(user));
}));
>
Exercise: Create a fully typed Express API that:
  1. Has properly typed routes with params, query, and body
  2. Uses custom middleware to attach user information to requests
  3. Implements async error handling with custom error classes
  4. Has type-safe environment variable configuration
  5. Returns consistent, typed API responses

Complete Example: Typed Express API

Full Application:
<// src/index.ts
import express from 'express';
import { config } from './config/env';
import { errorHandler } from './middleware/errorHandler';
import { authenticate } from './middleware/auth';
import userRoutes from './routes/userRoutes';

const app = express();

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

// Routes
app.use('/api/users', userRoutes);

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date() });
});

// Error handling
app.use(errorHandler);

// Start server
app.listen(config.port, () => {
  console.log(`Server running on port ${config.port} in ${config.env} mode`);
});
>
Summary: TypeScript transforms Node.js development by providing type safety, better tooling, and improved maintainability. Master these patterns to build robust, production-ready Node.js applications with confidence.