Skip to main content

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?

BenefitDescription
Type SafetyCatch errors before runtime
Better IDE SupportAutocomplete, refactoring, navigation
Self-DocumentingTypes serve as documentation
RefactoringSafe refactoring with confidence
Team ScalabilityEasier onboarding, clearer interfaces

Project Setup

# 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

{
  "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

{
  "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

// 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

// 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

// 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

// 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

// 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

npm install zod
// 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

// 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

npm install @prisma/client
npm install prisma -D
npx prisma init
// 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
}
// 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

// 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

// 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

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