| |

TypeScript 106 ๐Ÿ”ท Capstone โ€” Full Type-Safe Application

The previous chapters covered individual TypeScript features โ€” generics, conditional types, assertion functions, satisfies, the compiler pipeline, architecture patterns. Each one solves a specific problem. This chapter puts them together. It builds a complete application where every layer is type-safe: the database schema, the domain model, the HTTP boundary, the configuration, and the tests. Nothing is typed as any. Every boundary validates its input. Every internal type is derived from a single source of truth.

The application is a task management API. It is small enough to build in one chapter and large enough to exercise the patterns that a real codebase needs. The goal is not to build the most feature-rich task manager. The goal is to build one where the type system catches the mistakes that would otherwise reach production.

Key point: A fully type-safe application has a single source of truth for each concept, and every other representation is derived from it. The database schema defines the columns. The domain types are inferred from the schema. The API request and response types are derived from the domain types. The validation schemas are derived from the same types. When the schema changes, the compiler reports every place that needs to be updated. There is no place where two definitions of the same concept can drift apart.


Why a capstone matters

A tutorial teaches features in isolation. A capstone shows how they compose. The features that seem independent โ€” discriminated unions, assertion functions, satisfies, the compiler pipeline โ€” interact in ways that only appear when they are used together.

The boundary problem. Every external input is untrusted. A request body, a database row, an environment variable โ€” none of them are typed at runtime. The type system describes what they should be, but only validation makes them safe. The capstone shows where validation lives, what it produces, and how the rest of the application depends on the validated type.

The derivation problem. A task has a status field. The database stores it as a string. The API accepts it as a string. The domain model uses a union of literals. The UI displays a label. If each layer defines its own version of the status, they drift. The capstone shows how one definition flows through all layers.

The error problem. Errors are values in a typed system. A throw is a runtime effect that the type system does not track. A Result type is a value that the type system does. The capstone shows both patterns and where each is appropriate.

The test problem. A test that mocks five services is a test that documents the wiring of the application, not the behavior. The capstone shows how to structure the application so that tests are small and focused, and how to use the type system to make the tests themselves type-safe.

The trade-off. Full type safety requires discipline. Every boundary needs a validator. Every derived type needs to be tied to its source. Every function needs an explicit signature. The discipline is the cost. The benefit is a codebase where the compiler catches the mistakes that would otherwise reach the user.


a. The Single Source of Truth

The application starts with the database schema. Everything else derives from it.

// src/db/schema.ts
import { pgTable, uuid, text, timestamp, pgEnum } from 'drizzle-orm/pg-core';

export const taskStatusEnum = pgEnum('task_status', ['todo', 'in_progress', 'done']);
export const taskPriorityEnum = pgEnum('task_priority', ['low', 'medium', 'high']);

export const tasks = pgTable('tasks', {
  id: uuid('id').primaryKey().defaultRandom(),
  title: text('title').notNull(),
  description: text('description'),
  status: taskStatusEnum('status').notNull().default('todo'),
  priority: taskPriorityEnum('priority').notNull().default('medium'),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at').notNull().defaultNow(),
});

export type TaskRow = typeof tasks.$inferSelect;
export type NewTaskRow = typeof tasks.$inferInsert;

The pgTable call defines the schema. The $inferSelect and $inferInsert types extract the row types from the schema. TaskRow is the type of a row returned from a SELECT. NewTaskRow is the type of a row passed to an INSERT. Both are derived from the table definition, so they cannot drift.

// src/domain/task.ts
import type { TaskRow } from '../db/schema';

export type TaskStatus = TaskRow['status'];
export type TaskPriority = TaskRow['priority'];

export interface Task {
  readonly id: string;
  readonly title: string;
  readonly description: string | null;
  readonly status: TaskStatus;
  readonly priority: TaskPriority;
  readonly createdAt: Date;
  readonly updatedAt: Date;
}

export function toDomain(row: TaskRow): Task {
  return {
    id: row.id,
    title: row.title,
    description: row.description,
    status: row.status,
    priority: row.priority,
    createdAt: row.createdAt,
    updatedAt: row.updatedAt,
  };
}

The domain types are derived from the database schema. TaskStatus is TaskRow['status'], which is the union 'todo' | 'in_progress' | 'done' inferred from the enum. The Task interface is the domain representation. The toDomain function converts a row to a domain object. If the schema changes โ€” a new status is added, a column is renamed โ€” the compiler reports the places that need to be updated.


b. Validated Boundaries

Every external input is validated before it enters the application. The validation produces a typed value, and the rest of the application depends on the validated type.

// src/api/schemas.ts
import { z } from 'zod';

export const createTaskSchema = z.object({
  title: z.string().min(1).max(200),
  description: z.string().max(2000).optional(),
  priority: z.enum(['low', 'medium', 'high']).default('medium'),
});

export const updateTaskSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  description: z.string().max(2000).nullable().optional(),
  status: z.enum(['todo', 'in_progress', 'done']).optional(),
  priority: z.enum(['low', 'medium', 'high']).optional(),
});

export const taskIdSchema = z.string().uuid();

export const listTasksSchema = z.object({
  status: z.enum(['todo', 'in_progress', 'done']).optional(),
  priority: z.enum(['low', 'medium', 'high']).optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  offset: z.coerce.number().int().min(0).default(0),
});

export type CreateTaskInput = z.infer<typeof createTaskSchema>;
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>;
export type ListTasksInput = z.infer<typeof listTasksSchema>;

The Zod schemas validate the request bodies and query parameters. The z.infer utility extracts the TypeScript type from each schema. CreateTaskInput is the type of the parsed body after validation. The type and the runtime validator are the same definition.

// src/api/validate.ts
import type { Request, Response, NextFunction } from 'express';
import { ZodError, type AnyZodObject } from 'zod';

export function validateBody<T extends AnyZodObject>(schema: T) {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      req.body = schema.parse(req.body);
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        res.status(400).json({
          error: 'Validation failed',
          details: error.errors.map((e) => ({
            path: e.path.join('.'),
            message: e.message,
          })),
        });
        return;
      }
      next(error);
    }
  };
}

The validateBody middleware parses the request body with the schema. If it succeeds, the parsed value replaces req.body. If it fails, the response is a 400 with the validation errors. The handler receives the validated value, but the type of req.body is still any in the Express types. The handler must assert the type:

// src/api/tasks.ts
import type { Request, Response } from 'express';
import type { CreateTaskInput, UpdateTaskInput, ListTasksInput } from './schemas';
import type { TaskService } from '../application/task-service';

export function createTaskRouter(service: TaskService) {
  return {
    async list(req: Request, res: Response) {
      const query = req.query as unknown as ListTasksInput;
      const result = await service.list(query);
      res.json(result);
    },

    async create(req: Request, res: Response) {
      const input = req.body as CreateTaskInput;
      const task = await service.create(input);
      res.status(201).json(task);
    },

    async update(req: Request, res: Response) {
      const input = req.body as UpdateTaskInput;
      const task = await service.update(req.params.id, input);
      res.json(task);
    },

    async remove(req: Request, res: Response) {
      await service.remove(req.params.id);
      res.status(204).end();
    },
  };
}

The as CreateTaskInput assertion is safe because the middleware has already validated the body. The assertion tells the compiler what the runtime check has already guaranteed. This is one of the few places where an assertion is appropriate โ€” the validation happens at the boundary, and the assertion transfers the validated type into the handler.


c. The Service Layer and Error Handling

The service layer orchestrates the domain and the infrastructure. It receives validated input and returns typed results. Errors are values, not exceptions.

// src/domain/result.ts
export type Result<T, E = Error> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly error: E };

export function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

export function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

export function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
  return result.ok;
}

export function isErr<T, E>(result: Result<T, E>): result is { ok: false; error: E } {
  return !result.ok;
}

The Result type makes errors explicit. A function that returns Result<T, E> tells the caller that it might fail and what the failure looks like. The caller cannot forget to handle the error because the compiler requires the ok field to be checked.

// src/application/task-service.ts
import type { Task } from '../domain/task';
import { toDomain } from '../domain/task';
import type { Result } from '../domain/result';
import { ok, err } from '../domain/result';
import type { CreateTaskInput, UpdateTaskInput, ListTasksInput } from '../api/schemas';
import type { TaskRepository } from './ports';

export type TaskError =
  | { readonly code: 'not_found'; readonly id: string }
  | { readonly code: 'conflict'; readonly message: string }
  | { readonly code: 'persistence_failed'; readonly cause: unknown };

export class TaskService {
  constructor(private repository: TaskRepository) {}

  async list(input: ListTasksInput): Promise<Result<{ tasks: readonly Task[]; total: number }>> {
    try {
      const rows = await this.repository.findAll({
        status: input.status,
        priority: input.priority,
        limit: input.limit,
        offset: input.offset,
      });
      const total = await this.repository.count({
        status: input.status,
        priority: input.priority,
      });
      return ok({ tasks: rows.map(toDomain), total });
    } catch (cause) {
      return err({ code: 'persistence_failed', cause });
    }
  }

  async create(input: CreateTaskInput): Promise<Result<Task, TaskError>> {
    try {
      const row = await this.repository.insert({
        title: input.title,
        description: input.description ?? null,
        priority: input.priority,
      });
      return ok(toDomain(row));
    } catch (cause) {
      return err({ code: 'persistence_failed', cause });
    }
  }

  async update(id: string, input: UpdateTaskInput): Promise<Result<Task, TaskError>> {
    const existing = await this.repository.findById(id);
    if (!existing) {
      return err({ code: 'not_found', id });
    }
    try {
      const row = await this.repository.update(id, input);
      return ok(toDomain(row));
    } catch (cause) {
      return err({ code: 'persistence_failed', cause });
    }
  }

  async remove(id: string): Promise<Result<void, TaskError>> {
    const existing = await this.repository.findById(id);
    if (!existing) {
      return err({ code: 'not_found', id });
    }
    try {
      await this.repository.delete(id);
      return ok(undefined);
    } catch (cause) {
      return err({ code: 'persistence_failed', cause });
    }
  }
}

Every method returns a Result. The TaskError union describes every possible failure. The ok and err helpers construct the results. The caller must check result.ok before accessing result.value or result.error, and the compiler enforces this.


Complete Example Session

This session assembles the application: the repository, the router, the error handler, the configuration, and the tests.

// ============================================
// PART 1: THE REPOSITORY PORT
// ============================================

// src/application/ports.ts
import type { TaskRow, NewTaskRow } from '../db/schema';

export interface TaskFilters {
  readonly status?: 'todo' | 'in_progress' | 'done';
  readonly priority?: 'low' | 'medium' | 'high';
  readonly limit?: number;
  readonly offset?: number;
}

export interface TaskRepository {
  findAll(filters: TaskFilters): Promise<readonly TaskRow[]>;
  count(filters: Pick<TaskFilters, 'status' | 'priority'>): Promise<number>;
  findById(id: string): Promise<TaskRow | null>;
  insert(data: Pick<NewTaskRow, 'title' | 'description' | 'priority'>): Promise<TaskRow>;
  update(id: string, data: Partial<Pick<NewTaskRow, 'title' | 'description' | 'status' | 'priority'>>): Promise<TaskRow>;
  delete(id: string): Promise<void>;
}

// ============================================
// PART 2: THE POSTGRES REPOSITORY
// ============================================

// src/infrastructure/postgres-task-repository.ts
import { eq, and, sql } from 'drizzle-orm';
import { tasks } from '../db/schema';
import type { TaskRow, NewTaskRow } from '../db/schema';
import type { TaskRepository, TaskFilters } from '../application/ports';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';

export class PostgresTaskRepository implements TaskRepository {
  constructor(private db: NodePgDatabase) {}

  async findAll(filters: TaskFilters): Promise<readonly TaskRow[]> {
    const conditions = [];
    if (filters.status) conditions.push(eq(tasks.status, filters.status));
    if (filters.priority) conditions.push(eq(tasks.priority, filters.priority));

    const query = this.db.select().from(tasks);
    if (conditions.length > 0) {
      query.where(and(...conditions));
    }
    if (filters.limit) query.limit(filters.limit);
    if (filters.offset) query.offset(filters.offset);

    return query.orderBy(tasks.createdAt);
  }

  async count(filters: Pick<TaskFilters, 'status' | 'priority'>): Promise<number> {
    const conditions = [];
    if (filters.status) conditions.push(eq(tasks.status, filters.status));
    if (filters.priority) conditions.push(eq(tasks.priority, filters.priority));

    const result = await this.db
      .select({ count: sql<number>`count(*)::int` })
      .from(tasks)
      .where(conditions.length > 0 ? and(...conditions) : undefined);

    return result[0]?.count ?? 0;
  }

  async findById(id: string): Promise<TaskRow | null> {
    const result = await this.db.select().from(tasks).where(eq(tasks.id, id));
    return result[0] ?? null;
  }

  async insert(data: Pick<NewTaskRow, 'title' | 'description' | 'priority'>): Promise<TaskRow> {
    const result = await this.db.insert(tasks).values(data).returning();
    return result[0];
  }

  async update(
    id: string,
    data: Partial<Pick<NewTaskRow, 'title' | 'description' | 'status' | 'priority'>>,
  ): Promise<TaskRow> {
    const result = await this.db
      .update(tasks)
      .set({ ...data, updatedAt: new Date() })
      .where(eq(tasks.id, id))
      .returning();
    return result[0];
  }

  async delete(id: string): Promise<void> {
    await this.db.delete(tasks).where(eq(tasks.id, id));
  }
}

// ============================================
// PART 3: THE ERROR HANDLER
// ============================================

// src/api/error-handler.ts
import type { Request, Response, NextFunction } from 'express';
import type { TaskError } from '../application/task-service';

export class ApiError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly code: string,
    message: string,
  ) {
    super(message);
    Object.setPrototypeOf(this, ApiError.prototype);
  }
}

export function taskErrorToResponse(error: TaskError): { status: number; body: unknown } {
  switch (error.code) {
    case 'not_found':
      return { status: 404, body: { error: 'Task not found', id: error.id } };
    case 'conflict':
      return { status: 409, body: { error: error.message } };
    case 'persistence_failed':
      return { status: 500, body: { error: 'Internal server error' } };
  }
}

export function errorHandler(
  err: Error,
  _req: Request,
  res: Response,
  _next: NextFunction,
) {
  if (err instanceof ApiError) {
    res.status(err.statusCode).json({ error: err.message, code: err.code });
    return;
  }
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
}

// ============================================
// PART 4: THE ROUTER
// ============================================

// src/api/tasks-router.ts
import { Router } from 'express';
import { taskIdSchema, createTaskSchema, updateTaskSchema, listTasksSchema } from './schemas';
import { validateBody } from './validate';
import { taskErrorToResponse } from './error-handler';
import type { TaskService } from '../application/task-service';
import type { CreateTaskInput, UpdateTaskInput, ListTasksInput } from './schemas';

export function createTasksRouter(service: TaskService): Router {
  const router = Router();

  router.get('/', (req, res) => {
    const parsed = listTasksSchema.safeParse(req.query);
    if (!parsed.success) {
      res.status(400).json({ error: 'Invalid query', details: parsed.error.errors });
      return;
    }
    void service.list(parsed.data).then((result) => {
      if (!result.ok) {
        const response = taskErrorToResponse(result.error);
        res.status(response.status).json(response.body);
        return;
      }
      res.json(result.value);
    });
  });

  router.post('/', validateBody(createTaskSchema), (req, res) => {
    const input = req.body as CreateTaskInput;
    void service.create(input).then((result) => {
      if (!result.ok) {
        const response = taskErrorToResponse(result.error);
        res.status(response.status).json(response.body);
        return;
      }
      res.status(201).json(result.value);
    });
  });

  router.patch('/:id', (req, res) => {
    const idParsed = taskIdSchema.safeParse(req.params.id);
    if (!idParsed.success) {
      res.status(400).json({ error: 'Invalid task ID' });
      return;
    }
    const bodyParsed = updateTaskSchema.safeParse(req.body);
    if (!bodyParsed.success) {
      res.status(400).json({ error: 'Invalid body', details: bodyParsed.error.errors });
      return;
    }
    void service.update(idParsed.data, bodyParsed.data).then((result) => {
      if (!result.ok) {
        const response = taskErrorToResponse(result.error);
        res.status(response.status).json(response.body);
        return;
      }
      res.json(result.value);
    });
  });

  router.delete('/:id', (req, res) => {
    const parsed = taskIdSchema.safeParse(req.params.id);
    if (!parsed.success) {
      res.status(400).json({ error: 'Invalid task ID' });
      return;
    }
    void service.remove(parsed.data).then((result) => {
      if (!result.ok) {
        const response = taskErrorToResponse(result.error);
        res.status(response.status).json(response.body);
        return;
      }
      res.status(204).end();
    });
  });

  return router;
}

// ============================================
// PART 5: THE CONFIGURATION
// ============================================

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

const configSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

export type Config = z.infer<typeof configSchema>;

export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
  const parsed = configSchema.safeParse(env);
  if (!parsed.success) {
    console.error('Invalid configuration:');
    console.error(parsed.error.errors);
    process.exit(1);
  }
  return parsed.data;
}

// ============================================
// PART 6: THE COMPOSITION ROOT
// ============================================

// src/main.ts
import express from 'express';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { loadConfig } from './config';
import { PostgresTaskRepository } from './infrastructure/postgres-task-repository';
import { TaskService } from './application/task-service';
import { createTasksRouter } from './api/tasks-router';
import { errorHandler } from './api/error-handler';

const config = loadConfig();

const pool = new Pool({ connectionString: config.DATABASE_URL });
const db = drizzle(pool);
const repository = new PostgresTaskRepository(db);
const service = new TaskService(repository);

const app = express();
app.use(express.json());
app.use('/tasks', createTasksRouter(service));
app.use(errorHandler);

app.listen(config.PORT, () => {
  console.log(`Listening on port ${config.PORT}`);
});

// ============================================
// PART 7: THE IN-MEMORY TEST REPOSITORY
// ============================================

// src/application/task-service.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { TaskService } from './task-service';
import type { TaskRepository, TaskFilters } from './ports';
import type { TaskRow, NewTaskRow } from '../db/schema';

class InMemoryTaskRepository implements TaskRepository {
  private rows: TaskRow[] = [];
  private nextId = 1;

  async findAll(filters: TaskFilters): Promise<readonly TaskRow[]> {
    return this.rows.filter((row) => {
      if (filters.status && row.status !== filters.status) return false;
      if (filters.priority && row.priority !== filters.priority) return false;
      return true;
    });
  }

  async count(filters: Pick<TaskFilters, 'status' | 'priority'>): Promise<number> {
    const rows = await this.findAll(filters);
    return rows.length;
  }

  async findById(id: string): Promise<TaskRow | null> {
    return this.rows.find((row) => row.id === id) ?? null;
  }

  async insert(data: Pick<NewTaskRow, 'title' | 'description' | 'priority'>): Promise<TaskRow> {
    const row: TaskRow = {
      id: String(this.nextId++),
      title: data.title,
      description: data.description ?? null,
      status: 'todo',
      priority: data.priority ?? 'medium',
      createdAt: new Date(),
      updatedAt: new Date(),
    };
    this.rows.push(row);
    return row;
  }

  async update(id: string, data: Partial<Pick<NewTaskRow, 'title' | 'description' | 'status' | 'priority'>>): Promise<TaskRow> {
    const index = this.rows.findIndex((row) => row.id === id);
    if (index === -1) throw new Error('Not found');
    const updated = { ...this.rows[index], ...data, updatedAt: new Date() };
    this.rows[index] = updated;
    return updated;
  }

  async delete(id: string): Promise<void> {
    this.rows = this.rows.filter((row) => row.id !== id);
  }
}

describe('TaskService', () => {
  let service: TaskService;
  let repository: InMemoryTaskRepository;

  beforeEach(() => {
    repository = new InMemoryTaskRepository();
    service = new TaskService(repository);
  });

  it('creates a task with todo status', async () => {
    const result = await service.create({ title: 'Write tests', priority: 'high' });
    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.status).toBe('todo');
      expect(result.value.priority).toBe('high');
    }
  });

  it('returns not_found for a missing task', async () => {
    const result = await service.update('999', { title: 'Updated' });
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error.code).toBe('not_found');
    }
  });

  it('filters tasks by status', async () => {
    await service.create({ title: 'Task 1', priority: 'low' });
    await service.create({ title: 'Task 2', priority: 'high' });
    const result = await service.list({ limit: 10, offset: 0, priority: 'high' });
    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.tasks.length).toBe(1);
      expect(result.value.tasks[0].priority).toBe('high');
    }
  });
});

// ============================================
// PART 8: THE CONFIG TEST
// ============================================

// src/config.test.ts
import { describe, it, expect } from 'vitest';
import { loadConfig } from './config';

describe('loadConfig', () => {
  it('applies defaults for optional values', () => {
    const config = loadConfig({ DATABASE_URL: 'postgres://localhost/db' } as NodeJS.ProcessEnv);
    expect(config.NODE_ENV).toBe('development');
    expect(config.PORT).toBe(3000);
    expect(config.LOG_LEVEL).toBe('info');
  });

  it('coerces PORT to a number', () => {
    const config = loadConfig({
      DATABASE_URL: 'postgres://localhost/db',
      PORT: '8080',
    } as NodeJS.ProcessEnv);
    expect(config.PORT).toBe(8080);
  });
});

// ============================================
// PART 9: THE TYPECHECK SCRIPT
// ============================================

// package.json
{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "lint": "eslint .",
    "test": "vitest run",
    "dev": "tsx watch src/main.ts",
    "build": "tsc"
  }
}

// ============================================
// PART 10: THE FULL PIPELINE
// ============================================

// Every request flows through:
// 1. Express middleware (JSON parsing)
// 2. Zod validation (boundary check)
// 3. TaskService (orchestration)
// 4. TaskRepository (port)
// 5. PostgresTaskRepository (adapter)
// 6. Drizzle (query builder)
// 7. PostgreSQL (storage)
//
// Every response flows back through the same layers.
// Every type is derived from the schema.
// Every boundary validates its input.
// Every error is a typed value.

The ten parts cover the repository port, the Postgres repository, the error handler, the router, the configuration, the composition root, the in-memory test repository, the config test, the typecheck script, and the full pipeline.


Quick Reference

The Layers

LayerFileDepends On
Schemadb/schema.tsDrizzle
Domaindomain/task.tsSchema
Application portsapplication/ports.tsDomain
Application serviceapplication/task-service.tsDomain, Ports
API schemasapi/schemas.tsZod
API routerapi/tasks-router.tsService, Schemas
Infrastructureinfrastructure/postgres-task-repository.tsPorts, Schema
Composition rootmain.tsAll

The Derived Types

TypeDerived From
TaskRowtypeof tasks.$inferSelect
NewTaskRowtypeof tasks.$inferInsert
TaskStatusTaskRow['status']
TaskPriorityTaskRow['priority']
CreateTaskInputz.infer<typeof createTaskSchema>
UpdateTaskInputz.infer<typeof updateTaskSchema>
ListTasksInputz.infer<typeof listTasksSchema>
Configz.infer<typeof configSchema>

The Result Type

FunctionPurpose
ok(value)Construct a success
err(error)Construct a failure
isOk(result)Type guard for success
isErr(result)Type guard for failure

The Validation Boundaries

BoundaryValidatorProduces
Request bodycreateTaskSchemaCreateTaskInput
Request bodyupdateTaskSchemaUpdateTaskInput
Query paramslistTasksSchemaListTasksInput
Path paramstaskIdSchemastring (UUID)
EnvironmentconfigSchemaConfig
Database rows$inferSelectTaskRow

The Test Structure

TestRepositorySetup
Service testsInMemoryTaskRepositoryNone
Config testsloadConfigNone
API testsInMemoryTaskRepository + supertestExpress app

Best Practices

โœ… Do This:

// Derive domain types from the schema
export type TaskStatus = TaskRow['status'];                        // โœ…
// Validate every boundary with Zod
const parsed = createTaskSchema.safeParse(req.body);               // โœ…
// Return Result from service methods
async create(input: CreateTaskInput): Promise<Result<Task, TaskError>> { } // โœ…
// Use in-memory repositories in tests
class InMemoryTaskRepository implements TaskRepository { }         // โœ…
// Wire dependencies in the composition root
const repository = new PostgresTaskRepository(db);
const service = new TaskService(repository);                       // โœ…

โŒ Don’t Do This:

// Don't define the same type twice
type TaskStatus = 'todo' | 'in_progress' | 'done';
type TaskStatus = 'todo' | 'in_progress' | 'done';  // duplicate    // โŒ
// Don't trust req.body without validation
const input = req.body as CreateTaskInput;  // no validation       // โŒ
// Don't throw for expected errors
if (!task) throw new Error('Not found');  // use Result            // โŒ
// Don't instantiate dependencies inside methods
async create() {
  const repo = new PostgresTaskRepository(pool);  // untestable     // โŒ
}
// Don't skip the composition root
export const service = new TaskService(new PostgresTaskRepository(db)); // โŒ

Common Pitfalls

PitfallWhy It HappensFix
Type drift between layersTypes defined separatelyDerive from the schema
Unvalidated inputTrusting req.bodyValidate with Zod
Untestable serviceDependencies instantiated insideInject through constructor
Errors swallowedUsing throw for expected errorsUse Result
Config crashes at runtimeNo validationValidate with Zod
Test requires a databaseUsing the real repositoryUse an in-memory implementation

Real-World Examples

1. Schema-Derived Type

export type TaskRow = typeof tasks.$inferSelect;

2. Domain Type

export type TaskStatus = TaskRow['status'];

3. Zod Schema

export const createTaskSchema = z.object({ title: z.string().min(1) });

4. Inferred Input

export type CreateTaskInput = z.infer<typeof createTaskSchema>;

5. Result Type

export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

6. Service Method

async create(input: CreateTaskInput): Promise<Result<Task, TaskError>> { }

7. Repository Port

export interface TaskRepository {
  findAll(filters: TaskFilters): Promise<readonly TaskRow[]>;
}

8. Postgres Adapter

export class PostgresTaskRepository implements TaskRepository { }

9. In-Memory Test Double

class InMemoryTaskRepository implements TaskRepository { }

10. Composition Root

const service = new TaskService(new PostgresTaskRepository(db));

Visual

The Single Source of Truth

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  SCHEMA                                      โ”‚
โ”‚    db/schema.ts                              โ”‚
โ”‚      โ””โ”€ tasks table definition               โ”‚
โ”‚         โ”œโ”€ $inferSelect โ†’ TaskRow            โ”‚
โ”‚         โ””โ”€ $inferInsert โ†’ NewTaskRow         โ”‚
โ”‚                                              โ”‚
โ”‚  DERIVED:                                    โ”‚
โ”‚    TaskStatus = TaskRow['status']            โ”‚
โ”‚    TaskPriority = TaskRow['priority']        โ”‚
โ”‚    Task = { ...TaskRow fields, readonly }    โ”‚
โ”‚                                              โ”‚
โ”‚  VALIDATED:                                  โ”‚
โ”‚    CreateTaskInput = z.infer<schema>         โ”‚
โ”‚    UpdateTaskInput = z.infer<schema>         โ”‚
โ”‚    ListTasksInput = z.infer<schema>          โ”‚
โ”‚                                              โ”‚
โ”‚  One definition. Everything else derives.    โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Request Pipeline

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  REQUEST PIPELINE                            โ”‚
โ”‚                                              โ”‚
โ”‚  HTTP request                                โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ–ผ                                      โ”‚
โ”‚  Express JSON parser                         โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ–ผ                                      โ”‚
โ”‚  Zod validation โ”€โ”€> 400 if invalid           โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ–ผ                                      โ”‚
โ”‚  TaskService (typed input)                   โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ–ผ                                      โ”‚
โ”‚  TaskRepository (port)                       โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ–ผ                                      โ”‚
โ”‚  PostgresTaskRepository (adapter)            โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ–ผ                                      โ”‚
โ”‚  Drizzle ORM                                 โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ–ผ                                      โ”‚
โ”‚  PostgreSQL                                  โ”‚
โ”‚                                              โ”‚
โ”‚  Response flows back through the layers.     โ”‚
โ”‚  Every type is derived. Every boundary validates.โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Result Flow

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  RESULT FLOW                                 โ”‚
โ”‚                                              โ”‚
โ”‚  Service method:                             โ”‚
โ”‚    return ok(task)                           โ”‚
โ”‚       or                                     โ”‚
โ”‚    return err({ code: 'not_found', id })     โ”‚
โ”‚                                              โ”‚
โ”‚  Caller:                                     โ”‚
โ”‚    const result = await service.update(...)  โ”‚
โ”‚    if (!result.ok) {                         โ”‚
โ”‚      // result.error is TaskError            โ”‚
โ”‚      // narrowed by the code field           โ”‚
โ”‚      return taskErrorToResponse(result.error)โ”‚
โ”‚    }                                         โ”‚
โ”‚    // result.value is Task                   โ”‚
โ”‚                                              โ”‚
โ”‚  The compiler enforces the check.            โ”‚
โ”‚  The error is a value, not a throw.          โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Test Structure

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  TEST STRUCTURE                              โ”‚
โ”‚                                              โ”‚
โ”‚  Service test:                               โ”‚
โ”‚    InMemoryTaskRepository                    โ”‚
โ”‚      โ””โ”€ No database                          โ”‚
โ”‚      โ””โ”€ No HTTP                              โ”‚
โ”‚      โ””โ”€ Runs in milliseconds                 โ”‚
โ”‚                                              โ”‚
โ”‚  Config test:                                โ”‚
โ”‚    loadConfig({ ... })                       โ”‚
โ”‚      โ””โ”€ No environment                       โ”‚
โ”‚      โ””โ”€ No process                           โ”‚
โ”‚                                              โ”‚
โ”‚  API test:                                   โ”‚
โ”‚    InMemoryTaskRepository + supertest        โ”‚
โ”‚      โ””โ”€ Express app without listening        โ”‚
โ”‚      โ””โ”€ Real routing, fake persistence       โ”‚
โ”‚                                              โ”‚
โ”‚  The composition root is the only place      โ”‚
โ”‚  that knows about Postgres.                  โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
Single source of truthDatabase schema
Derived types$inferSelect, $inferInsert, indexed access
Boundary validationZod schemas
Inferred input typesz.infer<typeof schema>
Error representationResult<T, E> discriminated union
Service layerOrchestration, returns Result
Repository portInterface in the application layer
Postgres adapterImplementation in the infrastructure layer
Composition rootWires concrete implementations
Test doublesIn-memory implementations of ports
ConfigurationValidated with Zod at startup
Type checkingtsc --noEmit in CI

Key takeaways:

  • A fully type-safe application has a single source of truth for each concept. The database schema defines the columns and their types. The domain types, the API input types, and the validation schemas are all derived from it. When the schema changes, the compiler reports every place that needs to be updated.
  • Every external boundary validates its input. A request body, a query parameter, a path parameter, and an environment variable are all untrusted. Zod schemas validate them at the boundary and produce typed values. The rest of the application depends on the validated types.
  • Errors are values, not exceptions. A Result<T, E> type makes failure explicit. The caller must check result.ok before accessing result.value or result.error. The compiler enforces the check. The TaskError union describes every possible failure and narrows correctly when the code field is checked.
  • The service layer orchestrates the domain and the infrastructure. It receives validated input, calls the repository port, and returns a Result. It does not know whether the repository is backed by Postgres or an in-memory map.
  • The composition root is the only place that knows the concrete implementations. The main.ts file creates the pool, the repository, the service, and the router. The rest of the code depends on interfaces. This is what makes the tests fast: the test provides its own implementation of the port.
  • The configuration is validated at startup. The loadConfig function parses process.env with a Zod schema. If any variable is missing or invalid, the process exits with a clear error before the application starts. The Config type is inferred from the schema.
  • The test doubles implement the same interfaces as the production adapters. The InMemoryTaskRepository implements TaskRepository. The service test uses it instead of PostgresTaskRepository. The test does not need a database, and the behavior is identical from the service’s perspective.

Remember: Type safety is not a feature you add at the end. It is a property of how the application is structured. The schema is the source. The types are derived. The boundaries are validated. The errors are values. The dependencies are injected. The tests use the same interfaces as production. Every one of these decisions is a small constraint. Together, they produce an application where the compiler catches the mistakes that would otherwise reach the user.


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!