TypeScript with Node.js
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.
<# 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.
<{
"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"]
}
>
<{
"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.
<// 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.
<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()
});
}
);
>
Request<Params, ResBody, ReqBody, ReqQuery>
Typing Middleware Functions
Middleware functions in Express can be typed for better error detection and autocomplete support.
<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' });
}
});
>
Typing Async Route Handlers
Async route handlers require proper error handling. Create a wrapper to handle async errors automatically.
<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.
<// 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
>
Typing Database Models
Create type-safe database models and queries for better code quality.
<// 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.
<// 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.
<// 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));
}));
>
- Has properly typed routes with params, query, and body
- Uses custom middleware to attach user information to requests
- Implements async error handling with custom error classes
- Has type-safe environment variable configuration
- Returns consistent, typed API responses
Complete Example: Typed Express API
<// 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`);
});
>