TypeScript is the standard for building Node.js applications in 2025. These are the patterns we use for production TypeScript projects. TS adds static typing to JavaScript, catching errors at compile time. For Node.js applications, fewer runtime bugs and better IDE support are the main benefits. Here are the patterns and configurations we rely on for production code.

Project Setup

Optimal tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Package.json Scripts

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts",
    "test": "vitest run",
    "lint": "eslint src --ext .ts",
    "typecheck": "tsc --noEmit"
  }
}

Type Definitions

Using @types/node

Always install the Node.js type definitions:

npm install -D @types/node

Custom Type Declarations

For modules without types, create a declaration file:

// src/types/express.d.ts
declare module "express" {
  export interface Request {
    userId?: string;
  }
}

Strict Mode Settings

Always enable strict mode in tsconfig:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true
  }
}

This catches more errors at compile time:

// With strictNullChecks - catches potential null
function getUser(id: string): User | null {
  // ...
}

// Caller must handle null
const user = getUser("123");
if (user) {
  console.log(user.name);  // Safe access
}

Error Handling Patterns

Create a hierarchy of custom errors. See also Node.js Error Handling Patterns for comprehensive error handling approaches.

// src/errors/AppError.ts
export class AppError extends Error {
  constructor(
    public statusCode: number,
    public message: string,
    public isOperational = true
  ) {
    super(message);
    Object.setPrototypeOf(this, AppError.prototype);
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(404, `${resource} not found`);
  }
}

export class ValidationError extends AppError {
  constructor(message: string) {
    super(400, message);
  }
}

Async Error Handler Middleware

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      status: "error",
      message: err.message
    });
  }

  // Unknown error - log it
  console.error("Unexpected error:", err);
  return res.status(500).json({
    status: "error",
    message: "Internal server error"
  });
};

Async Route Handler Wrapper

// src/utils/asyncHandler.ts
import { Request, Response, NextFunction } from "express";

export const asyncHandler = (
  fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
) => {
  return (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};

// Usage in routes
router.get(
  "/users/:id",
  asyncHandler(async (req, res) => {
    const user = await UserService.findById(req.params.id);
    if (!user) throw new NotFoundError("User");
    res.json(user);
  })
);

API Response Typing

Generic Response Types

// src/types/response.ts
export interface ApiResponse<T> {
  status: "success" | "error";
  data?: T;
  message?: string;
  meta?: {
    page: number;
    limit: number;
    total: number;
  };
}

export interface PaginatedResponse<T> extends ApiResponse<T> {
  meta: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

Using with Express

// Sending typed responses
res.status(200).json({
  status: "success",
  data: users,
  meta: { page: 1, limit: 10, total: 100, totalPages: 10 }
} as PaginatedResponse<User[]>);

Zod for Runtime Validation

Validate incoming data at runtime:

import { z } from "zod";

export const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  age: z.number().min(0).optional()
});

export type CreateUserDto = z.infer<typeof CreateUserSchema>;

// Validate in controller
const validatedData = CreateUserSchema.parse(req.body);

Dependency Injection

Constructor Pattern

// src/services/UserService.ts
export interface IUserRepository {
  findById(id: string): Promise<User | null>;
  findAll(): Promise<User[]>;
}

export class UserService {
  constructor(private userRepository: IUserRepository) {}

  async getUser(id: string): Promise<User> {
    const user = await this.userRepository.findById(id);
    if (!user) throw new NotFoundError("User");
    return user;
  }
}

Using with tsyringe

// src/container.ts
import { container } from "tsyringe";
import { UserService } from "./services/UserService";
import { MongoUserRepository } from "./repositories/MongoUserRepository";

container.registerSingleton<IUserRepository>(
  "IUserRepository",
  MongoUserRepository
);

container.registerSingleton("UserService", UserService);

Testing Setup

Vitest Configuration

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    globals: true,
    setupFiles: ["./test/setup.ts"],
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"]
    }
  }
});

Mocking with Vitest

// test/UserService.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { UserService } from "../../src/services/UserService";

describe("UserService", () => {
  const mockRepository = {
    findById: vi.fn(),
    findAll: vi.fn()
  };

  const userService = new UserService(mockRepository);

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("should return user when found", async () => {
    const mockUser = { id: "1", name: "John" };
    mockRepository.findById.mockResolvedValue(mockUser);

    const result = await userService.getUser("1");

    expect(result).toEqual(mockUser);
    expect(mockRepository.findById).toHaveBeenCalledWith("1");
  });

  it("should throw NotFoundError when user not found", async () => {
    mockRepository.findById.mockResolvedValue(null);

    await expect(userService.getUser("999")).rejects.toThrow("User not found");
  });
});

Production Considerations

ts-node vs Compiled

For production, always compile and run JavaScript:

# Build
npm run build

# Run compiled
npm start

SWC for Faster Builds

For large projects, use SWC instead of tsc:

{
  "scripts": {
    "build": "tsc",
    "build:swc": "swc src -d dist --copy-files"
  }
}

Environment Variables

// src/config/index.ts
import { z } from "zod";

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]),
  PORT: z.string().default("3000"),
  DATABASE_URL: z.string(),
  JWT_SECRET: z.string()
});

export const env = envSchema.parse(process.env);

Graceful Shutdown

// src/index.ts
import { app } from "./app";

const server = app.listen(env.PORT, () => {
  console.log(`Server running on port ${env.PORT}`);
});

const shutdown = async () => {
  console.log("Shutting down gracefully...");
  server.close(() => {
    console.log("HTTP server closed");
    process.exit(0);
  });
};

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

Summary

  1. Use strict TypeScript - Catch errors at compile time
  2. Create custom error classes - Consistent error handling
  3. Use async handlers - Clean route handlers
  4. Type your API responses - Predictable contracts
  5. Use Zod for validation - Runtime type safety
  6. Set up proper testing - Vitest + mocks
  7. Compile for production - Don’t run tsc in production

Use these patterns to write TypeScript code that is easier to maintain and safer to deploy.

Learn more: TypeScript Handbook