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
| Layer | File | Depends On |
|---|---|---|
| Schema | db/schema.ts | Drizzle |
| Domain | domain/task.ts | Schema |
| Application ports | application/ports.ts | Domain |
| Application service | application/task-service.ts | Domain, Ports |
| API schemas | api/schemas.ts | Zod |
| API router | api/tasks-router.ts | Service, Schemas |
| Infrastructure | infrastructure/postgres-task-repository.ts | Ports, Schema |
| Composition root | main.ts | All |
The Derived Types
| Type | Derived From |
|---|---|
TaskRow | typeof tasks.$inferSelect |
NewTaskRow | typeof tasks.$inferInsert |
TaskStatus | TaskRow['status'] |
TaskPriority | TaskRow['priority'] |
CreateTaskInput | z.infer<typeof createTaskSchema> |
UpdateTaskInput | z.infer<typeof updateTaskSchema> |
ListTasksInput | z.infer<typeof listTasksSchema> |
Config | z.infer<typeof configSchema> |
The Result Type
| Function | Purpose |
|---|---|
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
| Boundary | Validator | Produces |
|---|---|---|
| Request body | createTaskSchema | CreateTaskInput |
| Request body | updateTaskSchema | UpdateTaskInput |
| Query params | listTasksSchema | ListTasksInput |
| Path params | taskIdSchema | string (UUID) |
| Environment | configSchema | Config |
| Database rows | $inferSelect | TaskRow |
The Test Structure
| Test | Repository | Setup |
|---|---|---|
| Service tests | InMemoryTaskRepository | None |
| Config tests | loadConfig | None |
| API tests | InMemoryTaskRepository + supertest | Express 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Type drift between layers | Types defined separately | Derive from the schema |
| Unvalidated input | Trusting req.body | Validate with Zod |
| Untestable service | Dependencies instantiated inside | Inject through constructor |
| Errors swallowed | Using throw for expected errors | Use Result |
| Config crashes at runtime | No validation | Validate with Zod |
| Test requires a database | Using the real repository | Use 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
| Item | Value |
|---|---|
| Single source of truth | Database schema |
| Derived types | $inferSelect, $inferInsert, indexed access |
| Boundary validation | Zod schemas |
| Inferred input types | z.infer<typeof schema> |
| Error representation | Result<T, E> discriminated union |
| Service layer | Orchestration, returns Result |
| Repository port | Interface in the application layer |
| Postgres adapter | Implementation in the infrastructure layer |
| Composition root | Wires concrete implementations |
| Test doubles | In-memory implementations of ports |
| Configuration | Validated with Zod at startup |
| Type checking | tsc --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 checkresult.okbefore accessingresult.valueorresult.error. The compiler enforces the check. TheTaskErrorunion describes every possible failure and narrows correctly when thecodefield 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.tsfile 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
loadConfigfunction parsesprocess.envwith a Zod schema. If any variable is missing or invalid, the process exits with a clear error before the application starts. TheConfigtype is inferred from the schema. - The test doubles implement the same interfaces as the production adapters. The
InMemoryTaskRepositoryimplementsTaskRepository. The service test uses it instead ofPostgresTaskRepository. 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!