> ## 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.

# 20. TypeScript with Node.js

> Build type-safe Node.js applications with TypeScript for better developer experience and fewer runtime errors.

# 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?

| 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

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.

```bash theme={null}
# 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.

```json theme={null}
{
  "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

```json theme={null}
{
  "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"
  }
}
```

<Tip>
  **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.
</Tip>

## 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.

```typescript theme={null}
// 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.

```typescript theme={null}
// 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

```typescript theme={null}
// 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()`.

```typescript theme={null}
// 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

```typescript theme={null}
// 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.

```bash theme={null}
npm install zod
```

```typescript theme={null}
// 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.

```typescript theme={null}
// 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.

```bash theme={null}
npm install @prisma/client
npm install prisma -D
npx prisma init
```

```prisma theme={null}
// 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
}
```

```typescript theme={null}
// 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.

```typescript theme={null}
// 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.

```typescript theme={null}
// 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
