learn.colinkim.dev

Node.js with TypeScript

Learn how to set up a Node.js project with TypeScript, type request/response objects, and structure a typed backend.

Running TypeScript on the server requires a slightly different setup than frontend projects. The code compiles to JavaScript that runs on Node.js instead of in a browser.

Project setup

Install TypeScript and types for Node.js:

pnpm add -D typescript @types/node

A Node.js tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Run with tsx for development (no separate compile step):

pnpm add -D tsx
npx tsx src/index.ts

For production, compile and run the output:

npx tsc
node dist/index.js

Request and response types

Express-style frameworks provide typed request and response objects. The types come from @types/express or framework-specific packages:

import express, { Request, Response } from "express";

const app = express();
app.use(express.json());

interface CreatePostBody {
  title: string;
  body: string;
  tags: string[];
}

interface PostParams {
  id: string;
}

app.post("/api/posts", (req: Request<{}, CreatePostBody>, res: Response) => {
  const { title, body, tags } = req.body;
  // title, body, and body are typed from CreatePostBody
  // ...
  res.status(201).json({ id: "1", title, body, tags });
});

app.get("/api/posts/:id", (req: Request<PostParams>, res: Response) => {
  const { id } = req.params;
  // id is string from PostParams
  // ...
});

The Request<Params, Body, Query> generic lets you type URL parameters, request body, and query string separately.

Configuration typing

Server configuration should be typed and validated at startup:

interface ServerConfig {
  port: number;
  databaseUrl: string;
  nodeEnv: "development" | "production" | "test";
  secretKey: string;
}

function loadConfig(): ServerConfig {
  const config = {
    port: Number(process.env.PORT) || 3000,
    databaseUrl: process.env.DATABASE_URL,
    nodeEnv: (process.env.NODE_ENV as ServerConfig["nodeEnv"]) || "development",
    secretKey: process.env.SECRET_KEY,
  };

  if (!config.databaseUrl) throw new Error("DATABASE_URL is required");
  if (!config.secretKey) throw new Error("SECRET_KEY is required");

  return config;
}

For production, use a schema validation library (covered in Module 10) instead of manual checks.

Service layers

Separate request handling from business logic. Services are plain functions or classes that operate on domain types:

// services/posts.ts
interface CreatePostInput {
  title: string;
  body: string;
  tags: string[];
}

interface Post {
  id: string;
  title: string;
  body: string;
  tags: string[];
  createdAt: Date;
}

async function createPost(input: CreatePostInput): Promise<Post> {
  // business logic — no HTTP details here
  return {
    id: crypto.randomUUID(),
    ...input,
    createdAt: new Date(),
  };
}

// routes/posts.ts
app.post("/api/posts", async (req, res) => {
  try {
    const post = await createPost(req.body);
    res.status(201).json(post);
  } catch (error) {
    res.status(400).json({ error: (error as Error).message });
  }
});

The service operates on domain types (CreatePostInput, Post). The route handles HTTP concerns (status codes, JSON serialization, error responses).

Typed errors

Services should return typed errors rather than throwing:

type ServiceResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: ValidationError | NotFoundError };

async function getPost(id: string): Promise<ServiceResult<Post>> {
  const post = await db.posts.find(id);
  if (!post) {
    return { ok: false, error: { kind: "not-found", resource: "post", id } };
  }
  return { ok: true, data: post };
}

The route layer translates service errors to HTTP responses:

app.get("/api/posts/:id", async (req, res) => {
  const result = await getPost(req.params.id);
  if (!result.ok) {
    if (result.error.kind === "not-found") {
      return res.status(404).json({ message: "Post not found" });
    }
    return res.status(400).json({ message: result.error.message });
  }
  res.json(result.data);
});

Persistence boundaries

The data access layer is the boundary between the database format and the domain model:

// Data layer — raw database row
interface PostRow {
  id: string;
  title: string;
  body: string;
  tags: string;  // JSON string in the database
  created_at: string;  // ISO date string
}

// Domain model — application type
interface Post {
  id: string;
  title: string;
  body: string;
  tags: string[];  // parsed array
  createdAt: Date;  // parsed date
}

// Mapper at the boundary
function postFromRow(row: PostRow): Post {
  return {
    id: row.id,
    title: row.title,
    body: row.body,
    tags: JSON.parse(row.tags),
    createdAt: new Date(row.created_at),
  };
}

This is the same DTO-to-domain pattern from Module 9, applied to database rows.

What to carry forward

  • Node.js projects use module: "NodeNext" and @types/node
  • tsx enables running TypeScript directly during development
  • request types use Request<Params, Body, Query> generics
  • separate business logic (services) from HTTP handling (routes)
  • services return typed results; routes translate to HTTP responses
  • database rows are DTOs — map them to domain models at the boundary

The final track covers library and public API design.

Progress

Quick checks

No quick checks in this lesson.

Mark lesson manually or answer quick checks to track progress.