DEV Community

Chad Dower
Chad Dower

Posted on

Building a CRUD API with Node.js, Express, and TypeScript: Type-Safe Database Operations

Why Type-Safe Database Operations Matter

The average production API handles thousands of database operations daily. Each operation is a potential point of failure - a misspelled field, an incorrect data type, or an undefined property access. In JavaScript, these errors only surface at runtime, often after deployment.

TypeScript transforms this landscape entirely. Instead of discovering that user.emial should be user.email in production, TypeScript catches it during development. Your IDE becomes an active collaborator, autocompleting database fields and warning about type mismatches before you even run the code.

Key benefits of type-safe CRUD operations:

  • Compile-time validation catches errors before runtime - no more "Cannot read property of undefined"
  • IntelliSense provides accurate autocomplete for all database fields and relationships
  • Refactoring becomes risk-free - change a field name and TypeScript shows you every affected location
  • Self-documenting code where types serve as inline documentation
  • Reduced testing burden since many errors are impossible to write

But the real magic happens when these benefits compound. A type-safe API isn't just about preventing bugs - it's about developer velocity. You write code faster when your IDE knows exactly what properties exist on every object. You ship with confidence when the compiler validates your database operations.

Prerequisites

Before we dive in, make sure you have:

  • Node.js 18+ installed (for native TypeScript support and modern async patterns)
  • Basic TypeScript knowledge (interfaces, types, generics)
  • Understanding of REST API concepts (HTTP methods, status codes)
  • PostgreSQL or MySQL installed locally (we'll use PostgreSQL in examples)
  • Familiarity with async/await patterns in JavaScript

You should also be comfortable with Express.js basics. If you've built a simple Express API before, you're ready for this tutorial.

Setting Up Your TypeScript Foundation

Let's start by creating a robust TypeScript configuration that enforces type safety throughout our application. The key is setting up strict compiler options that catch potential issues early.

First, initialize your project with the necessary dependencies. We'll use a specific set of packages that work seamlessly together for type-safe development:

npm init -y
npm install express cors dotenv
npm install -D typescript @types/node @types/express
npm install -D @types/cors tsx nodemon
Enter fullscreen mode Exit fullscreen mode

Now, let's configure TypeScript for maximum type safety. Create a tsconfig.json that enforces strict type checking:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "strictNullChecks": true,
    "strictPropertyInitialization": false
  }
}
Enter fullscreen mode Exit fullscreen mode

The strict: true flag is crucial here. It enables all strict type checking options, including strictNullChecks which ensures you handle potential null or undefined values explicitly. This single setting prevents countless runtime errors by forcing you to consider edge cases during development.

The strictPropertyInitialization: false setting is a pragmatic choice for working with ORMs. Many ORMs handle property initialization internally, and this setting prevents TypeScript from complaining about properties that the ORM will populate.

Structuring Your Project

A well-organized project structure is essential for maintaining type safety as your API grows. Create a structure that separates concerns while maintaining clear type boundaries:

// src/types/index.ts
export interface User {
  id: string;
  email: string;
  username: string;
  createdAt: Date;
  updatedAt: Date;
}

export interface CreateUserDTO {
  email: string;
  username: string;
  password: string;
}

export interface UpdateUserDTO {
  email?: string;
  username?: string;
}
Enter fullscreen mode Exit fullscreen mode

Notice how we define separate types for different operations. CreateUserDTO requires all fields for creation, while UpdateUserDTO makes fields optional for partial updates. This pattern ensures type safety at the API boundary - you can't accidentally require a password during an update operation.

DTOs (Data Transfer Objects) serve as contracts between your API and clients. They define exactly what data your endpoints accept and return, creating a type-safe boundary that protects your internal domain models from external changes.

Building the Express Server with TypeScript

Express and TypeScript form a powerful combination when configured correctly. The key is leveraging TypeScript's type system to catch errors that would normally surface at runtime.

Let's create a type-safe Express server with proper error handling and middleware configuration:

// src/app.ts
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';

const app = express();

// Middleware setup
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
Enter fullscreen mode Exit fullscreen mode

The Express type definitions provide excellent IntelliSense support. When you type req. in your IDE, you'll see all available properties and methods with their correct types. This autocomplete isn't just convenient - it prevents typos and incorrect property access.

Creating Type-Safe Request Handlers

The real power of TypeScript shines when handling requests. We can extend Express's type definitions to include our custom request bodies and parameters:

// src/types/express.ts
interface TypedRequestBody<T> extends Request {
  body: T;
}

interface TypedRequestParams<T> extends Request {
  params: T;
}

interface TypedRequest<T, U> extends Request {
  body: T;
  params: U;
}
Enter fullscreen mode Exit fullscreen mode

These generic interfaces create type-safe request handlers. Instead of req.body being any, it's now strictly typed to match your expectations. Here's how this transforms your route handlers:

// src/controllers/user.controller.ts
import { Response } from 'express';
import { TypedRequestBody } from '../types/express';
import { CreateUserDTO } from '../types';

export const createUser = async (
  req: TypedRequestBody<CreateUserDTO>,
  res: Response
) => {
  const { email, username, password } = req.body;
  // TypeScript knows these properties exist!
  // ... create user logic
};
Enter fullscreen mode Exit fullscreen mode

TypeScript now validates that req.body contains exactly the properties defined in CreateUserDTO. If you try to access req.body.invalidField, TypeScript immediately flags the error. This compile-time validation prevents a whole category of runtime errors.

Implementing Type-Safe Database Operations

Database operations are where type safety becomes absolutely critical. We'll use Prisma as our ORM because it generates TypeScript types directly from your database schema, creating an unbreakable link between your database and your code.

First, set up Prisma with a simple user model:

npm install prisma @prisma/client
npx prisma init
Enter fullscreen mode Exit fullscreen mode

Define your schema with careful attention to types and constraints:

// prisma/schema.prisma
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  username  String   @unique
  password  String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  posts     Post[]
}

model Post {
  id        String   @id @default(cuid())
  title     String
  content   String?
  published Boolean  @default(false)
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

After running npx prisma generate, Prisma creates TypeScript types that exactly match your database schema. These generated types ensure that every database query is type-safe. You literally cannot write a query that accesses a non-existent field - TypeScript won't compile it.

Repository Pattern for Database Abstraction

The Repository pattern creates a clean abstraction layer over your database operations. This pattern centralizes database logic and ensures consistent type safety across your application:

// src/repositories/user.repository.ts
import { PrismaClient, User } from '@prisma/client';
import { CreateUserDTO, UpdateUserDTO } from '../types';

export class UserRepository {
  constructor(private prisma: PrismaClient) {}

  async findById(id: string): Promise<User | null> {
    return this.prisma.user.findUnique({
      where: { id }
    });
  }

  async findByEmail(email: string): Promise<User | null> {
    return this.prisma.user.findUnique({
      where: { email }
    });
  }
  // ... more methods
}
Enter fullscreen mode Exit fullscreen mode

This repository provides several benefits. First, it centralizes all user-related database operations in one place. Second, it returns strongly-typed results - findById returns User | null, forcing you to handle the case where a user doesn't exist. Third, it makes testing easier since you can mock the repository instead of the entire database.

The repository pattern also enables powerful TypeScript features like discriminated unions for error handling:

// src/repositories/base.repository.ts
type Success<T> = { success: true; data: T };
type Failure = { success: false; error: string };
type Result<T> = Success<T> | Failure;

export abstract class BaseRepository<T> {
  protected handleError(error: unknown): Failure {
    const message = error instanceof Error 
      ? error.message 
      : 'Unknown error';
    return { success: false, error: message };
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating CRUD Endpoints

Now let's implement the CRUD endpoints with complete type safety from request to response. Each endpoint will validate input, perform database operations, and return typed responses.

Create Endpoint

The create endpoint demonstrates how TypeScript ensures data integrity throughout the request lifecycle:

// src/routes/user.routes.ts
import { Router } from 'express';
import { UserController } from '../controllers/user.controller';
import { validateCreateUser } from '../middleware/validation';

const router = Router();
const controller = new UserController();

router.post('/users', 
  validateCreateUser, 
  controller.create
);
Enter fullscreen mode Exit fullscreen mode

The validation middleware ensures the request body matches our expected schema before it reaches the controller. This layered approach provides multiple safety nets - TypeScript checks at compile time, and validation checks at runtime:

// src/middleware/validation.ts
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';

const createUserSchema = z.object({
  email: z.string().email(),
  username: z.string().min(3).max(20),
  password: z.string().min(8)
});

export const validateCreateUser = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const result = createUserSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ 
      errors: result.error.flatten() 
    });
  }
  next();
};
Enter fullscreen mode Exit fullscreen mode

Zod provides runtime validation that complements TypeScript's compile-time checks. While TypeScript ensures your code handles the right types, Zod ensures the actual data from clients matches those types. This dual-layer validation creates an impenetrable type barrier.

Read Endpoints

Read operations showcase TypeScript's ability to handle different response scenarios:

// src/controllers/user.controller.ts
export class UserController {
  async findOne(req: Request, res: Response) {
    const { id } = req.params;

    const user = await userRepository.findById(id);

    if (!user) {
      return res.status(404).json({ 
        error: 'User not found' 
      });
    }

    // Remove sensitive data
    const { password, ...userWithoutPassword } = user;
    return res.json(userWithoutPassword);
  }
}
Enter fullscreen mode Exit fullscreen mode

TypeScript's destructuring with rest parameters (...userWithoutPassword) creates a new type that excludes the password field. This pattern ensures you never accidentally send sensitive data in responses. The compiler knows exactly what fields remain in userWithoutPassword.

Update Endpoint

Update operations require careful handling of partial data. TypeScript's partial types ensure you only update fields that were actually provided:

// src/controllers/user.controller.ts
async update(
  req: TypedRequest<UpdateUserDTO, { id: string }>,
  res: Response
) {
  const { id } = req.params;
  const updateData = req.body;

  // Only update provided fields
  const updatedUser = await userRepository.update(id, {
    ...(updateData.email && { email: updateData.email }),
    ...(updateData.username && { username: updateData.username })
  });

  if (!updatedUser) {
    return res.status(404).json({ 
      error: 'User not found' 
    });
  }

  return res.json(updatedUser);
}
Enter fullscreen mode Exit fullscreen mode

The spread operator with conditional checks ensures we only include fields that were actually provided. This pattern prevents accidentally setting fields to undefined in the database. TypeScript tracks these conditional inclusions, ensuring type safety even with dynamic object construction.

Delete Endpoint

Delete operations need careful consideration of cascading effects and related data:

// src/controllers/user.controller.ts
async delete(req: Request, res: Response) {
  const { id } = req.params;

  try {
    await userRepository.delete(id);
    return res.status(204).send();
  } catch (error) {
    if (error.code === 'P2025') { // Prisma not found error
      return res.status(404).json({ 
        error: 'User not found' 
      });
    }
    throw error; // Let error handler catch other errors
  }
}
Enter fullscreen mode Exit fullscreen mode

Error Handling and Validation

Robust error handling is crucial for production APIs. TypeScript helps us create type-safe error handling that covers all edge cases:

// src/middleware/errorHandler.ts
interface ApiError extends Error {
  statusCode?: number;
  isOperational?: boolean;
}

export const errorHandler = (
  err: ApiError,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const statusCode = err.statusCode || 500;
  const message = err.isOperational 
    ? err.message 
    : 'Internal server error';

  res.status(statusCode).json({
    error: message,
    ...(process.env.NODE_ENV === 'development' && {
      stack: err.stack
    })
  });
};
Enter fullscreen mode Exit fullscreen mode

This error handler distinguishes between operational errors (like validation failures) and programming errors (like null reference exceptions). Operational errors return helpful messages to clients, while programming errors log details for debugging without exposing internal information.

Creating custom error classes adds another layer of type safety:

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

// Usage in controllers
if (!user) {
  throw new ApiError(404, 'User not found');
}
Enter fullscreen mode Exit fullscreen mode

Testing Your Type-Safe API

Type safety extends to testing, making your tests more reliable and easier to write:

// src/__tests__/user.test.ts
import request from 'supertest';
import { app } from '../app';
import { CreateUserDTO } from '../types';

describe('User API', () => {
  it('creates a user with valid data', async () => {
    const userData: CreateUserDTO = {
      email: 'test@example.com',
      username: 'testuser',
      password: 'securepass123'
    };

    const response = await request(app)
      .post('/api/users')
      .send(userData)
      .expect(201);

    expect(response.body).toHaveProperty('id');
    expect(response.body.email).toBe(userData.email);
  });
});
Enter fullscreen mode Exit fullscreen mode

TypeScript ensures your test data matches the exact shape expected by your API. If you add a required field to CreateUserDTO, TypeScript immediately flags all tests that need updating. This prevents the common problem of tests passing with incomplete data.

Performance Considerations

Type safety doesn't mean sacrificing performance. In fact, TypeScript can help you write more performant code by catching inefficient patterns during development.

Key optimization strategies:

  • Use projection in database queries to fetch only needed fields - Prisma's select clause is fully typed
  • Implement connection pooling with typed configuration to prevent connection exhaustion
  • Add typed caching layers for frequently accessed data using Redis with type-safe wrappers

Database query optimization becomes easier with TypeScript because you can see exactly what fields are being used:

// Efficient: Only fetch needed fields
const users = await prisma.user.findMany({
  select: {
    id: true,
    email: true,
    username: true
  }
});
// TypeScript knows users only have id, email, username
Enter fullscreen mode Exit fullscreen mode

Best Practices and Common Pitfalls

After building numerous type-safe APIs, certain patterns consistently prove valuable:

Best Practice 1: Use Branded Types for IDs

Prevent mixing up different entity IDs by creating branded types:

type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };

function deleteUser(id: UserId) { /* ... */ }
// Can't accidentally pass a PostId here!
Enter fullscreen mode Exit fullscreen mode

Best Practice 2: Validate Environment Variables

Type-check your environment variables at startup to catch configuration errors early:

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.string().regex(/^\d+$/).transform(Number),
  JWT_SECRET: z.string().min(32)
});

export const env = envSchema.parse(process.env);
// env is now fully typed!
Enter fullscreen mode Exit fullscreen mode

⚠️ Common Pitfall: Don't use any as an escape hatch. Every any in your codebase is a potential runtime error. Instead, use unknown and type guards to safely narrow types.

// BAD: Loses all type safety
const processData = (data: any) => {
  return data.value; // No type checking!
}

// GOOD: Maintains type safety
const processData = (data: unknown) => {
  if (isValidData(data)) {
    return data.value; // Type-safe!
  }
  throw new Error('Invalid data');
}
Enter fullscreen mode Exit fullscreen mode

When NOT to Use This Approach

While type-safe CRUD APIs provide numerous benefits, they're not always the right choice:

Consider alternatives if:

  • You're building a simple prototype or MVP where development speed trumps type safety
  • Your team lacks TypeScript experience and faces tight deadlines
  • You're working with a schema that changes extremely frequently (though Prisma handles this well)

For simple scripts or microservices with minimal database interaction, the overhead of setting up full type safety might outweigh the benefits. In these cases, a simpler JavaScript approach with runtime validation might suffice.

Conclusion

Building a type-safe CRUD API with TypeScript transforms the development experience from error-prone guesswork to confident, intellisense-guided coding. We've created a system where database fields autocomplete in your IDE, where refactoring updates every affected endpoint, and where many runtime errors become compile-time impossibilities.

The journey from JavaScript to TypeScript in backend development isn't just about adding types - it's about fundamentally changing how we think about API reliability. Each type annotation is a contract, each interface a guarantee, and each successful compilation a promise that entire categories of bugs cannot exist in your code.

Key Takeaways:

  • TypeScript's strict mode catches errors during development that would otherwise surface in production
  • The Repository pattern with TypeScript creates a type-safe abstraction over database operations
  • Combining compile-time type checking with runtime validation provides comprehensive data integrity

Next Steps:

  1. Implement authentication with type-safe JWT tokens and user sessions
  2. Add GraphQL with TypeGraphQL for type-safe schema definitions
  3. Explore advanced Prisma features like type-safe transactions and nested writes

Additional Resources


Found this helpful? Leave a comment below or share with your network!

Questions or feedback? I'd love to hear from you in the comments.

Top comments (0)