TypeScript with Node.js
TypeScript adds static typing to JavaScript, catching errors at compile time rather than runtime. For Node.js applications, TypeScript provides better IDE support, refactoring capabilities, and self-documenting code.Why TypeScript?
| Benefit | Description |
|---|---|
| Type Safety | Catch errors before runtime |
| Better IDE Support | Autocomplete, refactoring, navigation |
| Self-Documenting | Types serve as documentation |
| Refactoring | Safe refactoring with confidence |
| Team Scalability | Easier onboarding, clearer interfaces |
Project Setup
Copy
# Initialize project
mkdir my-ts-app && cd my-ts-app
npm init -y
# Install dependencies
npm install typescript ts-node @types/node -D
npm install express
npm install @types/express -D
# Initialize TypeScript config
npx tsc --init
tsconfig.json
Copy
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node",
"baseUrl": "./src",
"paths": {
"@/*": ["./*"],
"@config/*": ["config/*"],
"@controllers/*": ["controllers/*"],
"@models/*": ["models/*"],
"@services/*": ["services/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Package.json Scripts
Copy
{
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"lint": "eslint src/**/*.ts",
"test": "jest"
}
}
Basic Types
Copy
// Primitive types
const name: string = 'John';
const age: number = 30;
const isActive: boolean = true;
// Arrays
const numbers: number[] = [1, 2, 3];
const names: Array<string> = ['Alice', 'Bob'];
// Objects
const user: { name: string; age: number } = {
name: 'John',
age: 30
};
// Union types
let id: string | number = 1;
id = 'abc'; // Also valid
// Type aliases
type ID = string | number;
// Optional properties
interface User {
id: ID;
name: string;
email?: string; // Optional
}
// Function types
const add = (a: number, b: number): number => a + b;
// Async function types
const fetchUser = async (id: string): Promise<User> => {
// ...
};
// Void for no return
const logMessage = (msg: string): void => {
console.log(msg);
};
Interfaces and Types
Copy
// Interface for objects
interface User {
id: string;
email: string;
name: string;
createdAt: Date;
}
// Extending interfaces
interface Admin extends User {
role: 'admin';
permissions: string[];
}
// Type for unions and intersections
type UserRole = 'user' | 'admin' | 'moderator';
type UserWithRole = User & {
role: UserRole;
};
// Generic interfaces
interface ApiResponse<T> {
success: boolean;
data: T;
error?: string;
}
// Usage
const response: ApiResponse<User[]> = {
success: true,
data: users
};
Express with TypeScript
Basic Server Setup
Copy
// src/server.ts
import express, { Application } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { errorHandler } from './middleware/errorHandler';
import userRoutes from './routes/userRoutes';
const app: Application = express();
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use('/api/users', userRoutes);
app.use(errorHandler);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
export default app;
Typed Request Handlers
Copy
// src/types/express.d.ts
import { User } from './user';
declare global {
namespace Express {
interface Request {
user?: User;
}
}
}
// src/controllers/userController.ts
import { Request, Response, NextFunction } from 'express';
import { User, CreateUserDTO, UpdateUserDTO } from '../types/user';
import * as userService from '../services/userService';
export const getUsers = async (
req: Request,
res: Response<{ data: User[] }>,
next: NextFunction
): Promise<void> => {
try {
const users = await userService.findAll();
res.json({ data: users });
} catch (error) {
next(error);
}
};
export const getUser = async (
req: Request<{ id: string }>,
res: Response<{ data: User }>,
next: NextFunction
): Promise<void> => {
try {
const user = await userService.findById(req.params.id);
if (!user) {
res.status(404).json({ error: 'User not found' } as any);
return;
}
res.json({ data: user });
} catch (error) {
next(error);
}
};
export const createUser = async (
req: Request<{}, {}, CreateUserDTO>,
res: Response<{ data: User }>,
next: NextFunction
): Promise<void> => {
try {
const user = await userService.create(req.body);
res.status(201).json({ data: user });
} catch (error) {
next(error);
}
};
Typed Routes
Copy
// src/routes/userRoutes.ts
import { Router } from 'express';
import * as userController from '../controllers/userController';
import { validate } from '../middleware/validate';
import { createUserSchema, updateUserSchema } from '../schemas/userSchema';
import { auth } from '../middleware/auth';
const router = Router();
router.get('/', userController.getUsers);
router.get('/:id', userController.getUser);
router.post('/', validate(createUserSchema), userController.createUser);
router.put('/:id', auth, validate(updateUserSchema), userController.updateUser);
router.delete('/:id', auth, userController.deleteUser);
export default router;
Zod for Runtime Validation
Copy
npm install zod
Copy
// src/schemas/userSchema.ts
import { z } from 'zod';
export const createUserSchema = z.object({
body: z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(2).max(100)
})
});
export const updateUserSchema = z.object({
params: z.object({
id: z.string().uuid()
}),
body: z.object({
name: z.string().min(2).max(100).optional(),
email: z.string().email().optional()
})
});
// Infer types from schemas
export type CreateUserInput = z.infer<typeof createUserSchema>['body'];
export type UpdateUserInput = z.infer<typeof updateUserSchema>['body'];
// Validation middleware
import { AnyZodObject, ZodError } from 'zod';
export const validate = (schema: AnyZodObject) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
await schema.parseAsync({
body: req.body,
query: req.query,
params: req.params
});
next();
} catch (error) {
if (error instanceof ZodError) {
res.status(400).json({
success: false,
errors: error.errors.map(e => ({
path: e.path.join('.'),
message: e.message
}))
});
} else {
next(error);
}
}
};
Error Handling
Copy
// src/utils/errors.ts
export class AppError extends Error {
public readonly statusCode: number;
public readonly isOperational: boolean;
constructor(message: string, statusCode: number, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
export class ValidationError extends AppError {
public readonly errors: Array<{ field: string; message: string }>;
constructor(errors: Array<{ field: string; message: string }>) {
super('Validation failed', 400);
this.errors = errors;
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401);
}
}
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/errors';
interface ErrorResponse {
success: false;
error: string;
stack?: string;
errors?: Array<{ field: string; message: string }>;
}
export const errorHandler = (
err: Error,
req: Request,
res: Response<ErrorResponse>,
next: NextFunction
): void => {
if (err instanceof AppError) {
res.status(err.statusCode).json({
success: false,
error: err.message,
...(err instanceof ValidationError && { errors: err.errors })
});
return;
}
console.error('Unhandled error:', err);
res.status(500).json({
success: false,
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
...(process.env.NODE_ENV !== 'production' && { stack: err.stack })
});
};
Database with Prisma
Copy
npm install @prisma/client
npm install prisma -D
npx prisma init
Copy
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
name String
password String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(uuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
}
enum Role {
USER
ADMIN
}
Copy
// src/services/userService.ts
import { PrismaClient, User, Prisma } from '@prisma/client';
import bcrypt from 'bcrypt';
import { CreateUserInput, UpdateUserInput } from '../schemas/userSchema';
import { NotFoundError } from '../utils/errors';
const prisma = new PrismaClient();
// Type for user without password
type SafeUser = Omit<User, 'password'>;
const userSelect: Prisma.UserSelect = {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
updatedAt: true
};
export const findAll = async (): Promise<SafeUser[]> => {
return prisma.user.findMany({ select: userSelect });
};
export const findById = async (id: string): Promise<SafeUser | null> => {
return prisma.user.findUnique({
where: { id },
select: userSelect
});
};
export const findByEmail = async (email: string): Promise<User | null> => {
return prisma.user.findUnique({ where: { email } });
};
export const create = async (data: CreateUserInput): Promise<SafeUser> => {
const hashedPassword = await bcrypt.hash(data.password, 12);
return prisma.user.create({
data: {
...data,
password: hashedPassword
},
select: userSelect
});
};
export const update = async (
id: string,
data: UpdateUserInput
): Promise<SafeUser> => {
const user = await prisma.user.update({
where: { id },
data,
select: userSelect
});
if (!user) {
throw new NotFoundError('User');
}
return user;
};
export const remove = async (id: string): Promise<void> => {
await prisma.user.delete({ where: { id } });
};
Authentication Middleware
Copy
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { UnauthorizedError } from '../utils/errors';
interface JwtPayload {
userId: string;
role: string;
}
export const auth = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new UnauthorizedError('No token provided');
}
const decoded = jwt.verify(
token,
process.env.JWT_SECRET!
) as JwtPayload;
req.user = {
id: decoded.userId,
role: decoded.role
} as any;
next();
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
next(new UnauthorizedError('Invalid token'));
} else {
next(error);
}
}
};
export const authorize = (...roles: string[]) => {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
next(new UnauthorizedError());
return;
}
if (!roles.includes(req.user.role)) {
next(new UnauthorizedError('Insufficient permissions'));
return;
}
next();
};
};
Testing with TypeScript
Copy
// src/__tests__/userService.test.ts
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import * as userService from '../services/userService';
import { prisma } from '../lib/prisma';
describe('UserService', () => {
beforeEach(async () => {
await prisma.user.deleteMany();
});
describe('create', () => {
it('should create a new user', async () => {
const userData = {
email: 'test@example.com',
password: 'password123',
name: 'Test User'
};
const user = await userService.create(userData);
expect(user).toMatchObject({
email: userData.email,
name: userData.name
});
expect(user).toHaveProperty('id');
expect(user).not.toHaveProperty('password');
});
it('should hash the password', async () => {
const userData = {
email: 'test@example.com',
password: 'password123',
name: 'Test User'
};
await userService.create(userData);
const dbUser = await prisma.user.findUnique({
where: { email: userData.email }
});
expect(dbUser?.password).not.toBe(userData.password);
});
});
});
Project Structure
Copy
src/
├── config/
│ ├── database.ts
│ └── env.ts
├── controllers/
│ └── userController.ts
├── middleware/
│ ├── auth.ts
│ ├── errorHandler.ts
│ └── validate.ts
├── models/
│ └── (Prisma handles this)
├── routes/
│ ├── index.ts
│ └── userRoutes.ts
├── schemas/
│ └── userSchema.ts
├── services/
│ └── userService.ts
├── types/
│ ├── express.d.ts
│ └── user.ts
├── utils/
│ ├── errors.ts
│ └── helpers.ts
├── __tests__/
│ └── userService.test.ts
├── app.ts
└── server.ts
Summary
- TypeScript adds static typing to Node.js
- Use strict mode for maximum type safety
- Zod provides runtime validation with type inference
- Prisma offers type-safe database access
- Extend Express types for custom request properties
- Create custom error classes for typed error handling
- Use generics for reusable, type-safe code
- Configure path aliases for cleaner imports