Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

TypeScript with Node.js

TypeScript adds static typing to JavaScript, catching errors at compile time rather than runtime. Think of it as a spell-checker for your code: just as a spell-checker catches typos before you send an email, TypeScript catches type errors before your code runs in production. For Node.js applications, TypeScript provides better IDE support (autocomplete that actually knows what properties an object has), safer refactoring (rename a function and the compiler tells you every call site that needs updating), and self-documenting code (the types themselves serve as documentation that cannot drift out of date).

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

Setting up TypeScript in a Node.js project requires a few extra dependencies compared to plain JavaScript. The typescript package is the compiler itself. ts-node lets you run .ts files directly without a separate compile step (essential for development). The @types/* packages provide type definitions for libraries written in plain JavaScript — they are the Rosetta Stone that teaches TypeScript what express, node, and other libraries look like.
# Initialize project
mkdir my-ts-app && cd my-ts-app
npm init -y

# Install dependencies
# -D means devDependency -- TypeScript and type definitions are build tools,
# not runtime code. They should not ship in your production node_modules.
npm install typescript ts-node @types/node -D
npm install express
npm install @types/express -D

# Initialize TypeScript config -- creates tsconfig.json with documented defaults
npx tsc --init

tsconfig.json

The tsconfig.json file is the control panel for the TypeScript compiler. Each option below is annotated with why it matters.
{
  "compilerOptions": {
    "target": "ES2022",              // Output JS syntax level -- ES2022 supports top-level await, etc.
    "module": "commonjs",            // Use require/module.exports (standard Node.js module system)
    "lib": ["ES2022"],               // Which built-in type definitions to include
    "outDir": "./dist",              // Compiled .js files go here
    "rootDir": "./src",              // Source .ts files live here
    "strict": true,                  // Enable ALL strict type checks -- always use this
    "esModuleInterop": true,         // Allows 'import express from "express"' syntax
    "skipLibCheck": true,            // Skip type-checking .d.ts files (faster builds)
    "forceConsistentCasingInFileNames": true, // Prevent bugs on case-insensitive file systems
    "resolveJsonModule": true,       // Allow importing .json files
    "declaration": true,             // Generate .d.ts type declaration files
    "declarationMap": true,          // Source maps for declarations (enables "Go to Definition")
    "sourceMap": true,               // Maps compiled JS back to TS for debugging
    "moduleResolution": "node",      // Use Node.js module resolution algorithm
    "baseUrl": "./src",              // Base for path aliases below
    "paths": {                       // Path aliases -- replace ugly relative imports
      "@/*": ["./*"],                // import { foo } from '@/utils' instead of '../../../utils'
      "@config/*": ["config/*"],
      "@controllers/*": ["controllers/*"],
      "@models/*": ["models/*"],
      "@services/*": ["services/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Package.json Scripts

{
  "scripts": {
    // ts-node-dev watches for file changes and restarts automatically.
    // --respawn: restart on crash instead of exiting.
    // --transpile-only: skip type checking for faster restarts (your IDE handles type checking).
    "dev": "ts-node-dev --respawn --transpile-only src/server.ts",
    "build": "tsc",                    // Compile TypeScript to JavaScript in ./dist
    "start": "node dist/server.js",    // Run the compiled JavaScript in production
    "lint": "eslint src/**/*.ts",
    "test": "jest"
  }
}
Node.js tip: In production, always run the compiled JavaScript (node dist/server.js), never ts-node directly. ts-node adds startup overhead and memory usage from the TypeScript compiler. Your CI/CD pipeline should run npm run build and deploy only the dist/ folder.

Basic Types

TypeScript’s type system builds on JavaScript’s existing types but makes them explicit and enforced. In plain JavaScript, a variable can silently change from a number to a string to an object — TypeScript prevents that. Think of types as contracts: when you declare age: number, you are promising that age will always be a number, and the compiler holds you to it.
// Primitive types -- TypeScript can often infer these automatically,
// but explicit annotations serve as documentation for other developers.
const name: string = 'John';
const age: number = 30;
const isActive: boolean = true;

// Arrays -- two equivalent syntaxes, choose one for consistency
const numbers: number[] = [1, 2, 3];           // Preferred in most codebases
const names: Array<string> = ['Alice', 'Bob'];  // Generic syntax (same result)

// Inline object type -- fine for one-off use, but use an interface for reusable shapes
const user: { name: string; age: number } = {
  name: 'John',
  age: 30
};

// Union types -- the variable can be ONE of the listed types.
// This is incredibly useful for IDs (sometimes strings, sometimes numbers)
// and for function parameters that accept multiple formats.
let id: string | number = 1;
id = 'abc'; // Also valid -- TypeScript tracks the current type through narrowing

// Type aliases -- give a name to a type expression for reuse and readability
type ID = string | number;

// Optional properties -- the ? means the field can be undefined
interface User {
  id: ID;
  name: string;
  email?: string; // Optional -- may or may not be present
}

// Function types -- annotate parameters AND return type.
// The return type annotation is optional (TypeScript can infer it),
// but explicit return types catch mistakes where you accidentally return the wrong thing.
const add = (a: number, b: number): number => a + b;

// Async functions always return a Promise wrapping the actual return type
const fetchUser = async (id: string): Promise<User> => {
  // ...
};

// Void means the function does not return a value
const logMessage = (msg: string): void => {
  console.log(msg);
};

Interfaces and Types

TypeScript offers two ways to define the shape of data: interface and type. The difference is mostly stylistic for simple objects, but there are real differences. Interfaces are extendable (you can add fields to an existing interface by declaring it again — this is called “declaration merging”), while types are better for unions, intersections, and computed types. The Node.js/Express ecosystem convention is to use interface for object shapes and type for everything else.
// Interface for objects -- describes "what shape does this data have?"
interface User {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
}

// Extending interfaces -- Admin has all User fields plus its own
interface Admin extends User {
  role: 'admin';
  permissions: string[];
}

// Type for unions -- a value can be one of several options
type UserRole = 'user' | 'admin' | 'moderator';

// Intersection type -- combines two types into one (User AND role)
type UserWithRole = User & {
  role: UserRole;
};

// Generic interfaces -- the T is a placeholder that gets filled in at usage time.
// This is like a template: one definition, many concrete types.
interface ApiResponse<T> {
  success: boolean;
  data: T;
  error?: string;  // The ? means this field is optional
}

// Usage -- T becomes User[] here, so data is typed as User[]
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

One of the most powerful aspects of TypeScript with Express is the ability to type your request parameters, body, and response. Express’s Request type accepts generic parameters: Request<Params, ResBody, ReqBody, Query>. By filling these in, your handler gets autocomplete and type checking for req.params, req.body, and res.json().
// src/types/express.d.ts
// This "declaration merging" file extends Express's built-in Request type
// to include a user property. Without this, TypeScript does not know
// about req.user and would flag it as an error.
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';

// Response<{ data: User[] }> tells TypeScript that res.json() expects
// an object with a data property containing a User array.
// If you try to send something else, the compiler catches it.
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);
  }
};

// Request<{ id: string }> types req.params -- TypeScript now knows
// req.params.id exists and is a string. Typos like req.params.userId
// are caught at compile time, not discovered in production.
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);
  }
};

// Request<{}, {}, CreateUserDTO> -- the third generic parameter types req.body.
// This ensures req.body.email, req.body.password, req.body.name all have
// the types defined in CreateUserDTO with full autocomplete support.
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

Here is a subtlety that trips up TypeScript beginners: TypeScript types exist only at compile time. They are completely erased when your code runs. This means TypeScript cannot validate data that arrives at runtime — like HTTP request bodies, environment variables, or API responses from external services. The data could be anything. Zod bridges this gap. It lets you define a schema that validates data at runtime AND automatically infers a TypeScript type from that schema. One definition, both compile-time types and runtime validation. No duplication, no drift between your types and your validation logic.
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 -- this is the key insight: the TypeScript type
// is DERIVED from the runtime schema, so they can never go out of sync.
// Change the schema, and the type updates automatically.
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

TypeScript makes error handling more robust by letting you define typed error hierarchies. The public readonly modifiers in the constructor are a TypeScript shorthand that simultaneously declares the property on the class and assigns the constructor argument to it — avoiding the repetitive this.statusCode = statusCode pattern.
// 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

Prisma is the ideal database tool for TypeScript projects because it generates a fully-typed client from your schema file. Every query, every result, every relation is type-checked at compile time. If you rename a column in your schema and forget to update a query, TypeScript catches it immediately. This level of type safety is simply not possible with raw SQL or loosely-typed ORMs.
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();

// Omit<T, K> is a built-in TypeScript utility type that creates a new type
// with all properties of T except K. Here, SafeUser is a User without the
// password field -- perfect for API responses where you must never leak password hashes.
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

TypeScript shines in authentication middleware because it catches common mistakes like accessing properties that do not exist on the decoded token. The as JwtPayload type assertion below tells TypeScript the shape of the decoded token — if you later change the token structure, the types will guide you to update every consumer.
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { UnauthorizedError } from '../utils/errors';

// Define the exact shape of your JWT payload.
// This interface is the contract between token creation and token consumption.
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');
    }

    // The ! (non-null assertion) tells TypeScript "I know this is defined."
    // This is safe only because we validate required env vars at startup (see env.ts).
    // Without that validation, this could crash at runtime with "undefined" as the secret.
    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

Testing TypeScript code with Jest requires a small amount of configuration. Install ts-jest to let Jest understand .ts files, or use @swc/jest for faster compilation. The key benefit: your test code is also type-checked, so typos in test assertions are caught at compile time rather than producing confusing test failures.
// 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

A well-organized TypeScript project separates concerns into clearly-named directories. This structure mirrors the MVC (Model-View-Controller) pattern adapted for APIs: schemas handle input validation, services contain business logic, controllers handle HTTP concerns, and routes wire everything together. The types/ directory holds custom type definitions, and utils/ contains shared helpers.
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