Back to blog

Schema-Driven REST APIs in TypeScript

10 min read

Cover image for Schema-Driven REST APIs in TypeScript
Image crafted by robots

Most REST APIs promise a clean contract but deliver a router file, a global db import, and catch (e: unknown). The API stops being a contract and becomes a dressed-up query layer. Internal column names leak into responses. Errors are strings. Your callers don’t care about your columns. They care about the contract: what fields belong to them, what the server owns, and what can go wrong.

A machine-readable spec makes that contract real for humans, generated clients, and LLMs alike. But a spec is only as honest as its implementation.

This approach is schema-driven. Schemas define the API contract and drive validation, types, and OpenAPI output, even when derived from the database model. Three things make the contract honest:

Schemas

The storage schema defines how data lives in the database. The contract schema defines what callers see. The route schema ties both to HTTP. Get these right and validation, types, and documentation all follow from one source.

The table

The table describes how data is stored. Not how callers interact with it.

Note

Imports throughout this guide use a @/* path alias pointing to src/.

src/db/schemas/posts.ts
import { sql } from "drizzle-orm";
import { sqliteTable } from "drizzle-orm/sqlite-core";
import { typeid } from "typeid-js";
export const postsTable = sqliteTable("posts", (t) => ({
createdAt: t
.integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
id: t
.text()
.primaryKey()
.$defaultFn(() => typeid("post").toString()),
body: t.text().notNull(),
title: t.text().notNull(),
updatedAt: t
.integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}));

From here, define what callers actually see.

The contract

This is where most APIs go wrong. They expose the table directly. Callers can send id, createdAt, updatedAt. A column rename can ripple through every client.

Never let callers set server-owned fields. Only expose the fields callers own. drizzle-orm derives a starting point from the table, but those .pick(), .omit(), and .partial() calls shape the actual contract. Derivation gets you started. The shaping calls are the design.

Start with the response shape and the creation input:

src/posts/posts.schema.ts
import { z } from "@hono/zod-openapi";
import { createSchemaFactory } from "drizzle-orm/zod";
import { postsTable } from "@/db/schemas/posts";
const { createInsertSchema, createSelectSchema, createUpdateSchema } =
createSchemaFactory({ zodInstance: z });
export const Post = createSelectSchema(postsTable, {
body: z.string().openapi({
example: "Drizzle derives Zod. Zod drives OpenAPI. Types follow for free.",
}),
id: z.string().openapi({ example: "post_01h2xcejqtf2nbrexx3vqjhp41" }),
title: z.string().openapi({ example: "Schema-Driven APIs" }),
}).openapi("Post");
export type Post = z.infer<typeof Post>;
export const NewPostBody = createInsertSchema(postsTable, {
body: z.string().min(1).openapi({
example: "Drizzle derives Zod. Zod drives OpenAPI. Types follow for free.",
}),
title: z.string().min(1).openapi({ example: "Schema-Driven APIs" }),
})
.pick({ body: true, title: true })
.openapi("NewPostBody");
export type NewPostBody = z.infer<typeof NewPostBody>;

Updates use the same owned fields, but all optional. A caller can update just the title without touching the body. A shared error shape covers every failure response:

export const UpdatePostBody = createUpdateSchema(postsTable, {
body: z.string().min(1).openapi({ example: "Updated body." }),
title: z.string().min(1).openapi({ example: "Updated title" }),
})
.pick({ body: true, title: true })
.partial()
.openapi("UpdatePostBody");
export type UpdatePostBody = z.infer<typeof UpdatePostBody>;
export const ApiError = z
.object({
details: z.unknown().optional(),
message: z
.string()
.openapi({ example: "Your request did not match the expected schema." }),
status: z.number().int().min(100).max(599).openapi({ example: 422 }),
})
.openapi("ApiError");

A shared ApiError gives every failure the same shape. Clients parse errors uniformly and generated SDKs get a single error type. The spec documents failure modes that match what the server actually returns.

The data flow: Drizzle table → drizzle-orm/zod → Zod schemas → .openapi() → OpenAPI spec → inferred TypeScript types. Change the table and the derived schemas move with it. But the .pick() calls are still yours to own. They’re the contract, and they don’t change just because the table does.

The errors

The contract also includes failure modes. Your spec promises a 404 with an ApiError body. A 500 with a message. Without typed representations, those promises live in comments or nowhere at all.

src/posts/posts.errors.ts
import { Data } from "effect";
export class PostNotFoundError extends Data.TaggedError("PostNotFoundError")<{
postId: string;
}> {}
export class DatabaseError extends Data.TaggedError("DatabaseError")<{
cause: unknown;
}> {}

Data.TaggedError gives each error a _tag string literal in its TypeScript type. The compiler tells you if you forget to handle one.

The routes

The route definition is where contract schemas, error shapes, and HTTP behavior meet. A GET route declares the param it expects, every response it can return, and the schemas for each:

src/posts/posts.api.ts
import { createRoute, z } from "@hono/zod-openapi";
import { ApiError, NewPostBody, Post } from "./posts.schema";
const PostIdParam = z
.object({
postId: z.string().openapi({ example: "post_01h2xcejqtf2nbrexx3vqjhp41" }),
})
.openapi("PostIdParam");
export const GetPostRoute = createRoute({
description: "Retrieve a specific post by its ID.",
method: "get",
path: "/posts/{postId}",
request: { params: PostIdParam },
responses: {
200: {
content: { "application/json": { schema: Post } },
description: "The requested post.",
},
404: {
content: { "application/json": { schema: ApiError } },
description: "Post not found.",
},
422: {
content: { "application/json": { schema: ApiError } },
description: "Validation error.",
},
500: {
content: { "application/json": { schema: ApiError } },
description: "Internal server error.",
},
},
summary: "Get post by ID",
tags: ["Posts"],
});

The POST route follows the same pattern: NewPostBody for input, Post for the 201 response, and ApiError for 422 and 500 failures. Return a status code not declared in responses and TypeScript tells you. Hono’s defaultHook handles all validation failures with a consistent 422.

At this point the HTTP contract is fully described. Every input, output, and error shape has validation and types. Each one has an OpenAPI entry. But nothing stops you from ignoring all of it in the implementation. You could import the database directly, swallow errors with try/catch, return a 404 with the wrong body. The contract needs enforcement.

Layers

This is where most schema-driven API posts stop. They define the route contract but never force the implementation to respect it. Effect closes that gap. Layers make dependencies explicit and errors typed, and because implementations are swappable, the compiler checks what comments cannot.

The database as a dependency

A global db import works until you need to test without a database, swap backends, or trace where queries come from. Declare a DbService tag instead:

src/db/client.ts
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Config, Context, Effect, Layer, Redacted } from "effect";
type DrizzleClient = ReturnType<typeof drizzle>;
export class DbService extends Context.Tag("DbService")<
DbService,
{ readonly db: DrizzleClient }
>() {}
export const DbLive = Layer.effect(
DbService,
Effect.gen(function* () {
const databaseUrl = yield* Config.redacted("DATABASE_URL");
const sqlite = new Database(Redacted.value(databaseUrl));
return { db: drizzle({ client: sqlite }) };
}),
);

DbLive satisfies the tag with a real SQLite file. For tests, DbTest satisfies it with an in-memory database. Same interface, nothing mocked:

export const DbTest = Layer.effect(
DbService,
Effect.sync(() => {
const sqlite = new Database(":memory:");
sqlite.run(`
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
"createdAt" INTEGER NOT NULL DEFAULT (unixepoch()),
"updatedAt" INTEGER NOT NULL DEFAULT (unixepoch())
)
`);
return { db: drizzle({ client: sqlite }) };
}),
);

All code depends on DbService, not on DbLive or DbTest. The call site decides which runs. This matters in a moment.

The repository

The repo bridges domain rules and the database. When findById returns zero rows, it doesn’t hand back undefined. It fails with a typed PostNotFoundError, the same error the contract promises as a 404.

src/posts/posts.repo.ts
import { eq } from "drizzle-orm";
import { Effect } from "effect";
import { DbService } from "@/db/client";
import { postsTable } from "@/db/schemas/posts";
import { DatabaseError, PostNotFoundError } from "./posts.errors";
import type { NewPostBody } from "./posts.schema";
export const findById = (id: string) => {
return Effect.gen(function* () {
const { db } = yield* DbService;
const [post] = yield* Effect.tryPromise({
catch: (cause) => new DatabaseError({ cause }),
try: () =>
db.select().from(postsTable).where(eq(postsTable.id, id)).limit(1),
});
if (!post) {
return yield* Effect.fail(new PostNotFoundError({ postId: id }));
}
return post;
});
};

There is no try/catch anywhere. yield* DbService declares a dependency the type system tracks until a layer provides it. Effect.tryPromise wraps thrown exceptions into a typed DatabaseError. Effect.fail produces a typed failure that propagates through the call stack.

The HTTP translation

Translate domain outcomes into the HTTP responses the contract declared. GetPostRoute defined a 200, 404, and 500. The handler matches those exactly:

src/posts/posts.http.ts
import { Effect, Either } from "effect";
import { DbLive } from "@/db/client";
import { hono } from "@/lib/hono";
import { CreatePostRoute, GetPostRoute } from "./posts.api";
import * as postsRepo from "./posts.repo";
const app = hono();
app.openapi(GetPostRoute, async (c) => {
const { postId } = c.req.valid("param");
const result = await Effect.runPromise(
postsRepo.findById(postId).pipe(Effect.provide(DbLive), Effect.either),
);
if (Either.isLeft(result)) {
const err = result.left;
if (err._tag === "PostNotFoundError") {
return c.json({ message: `Post ${postId} not found`, status: 404 }, 404);
}
return c.json({ message: "Internal server error", status: 500 }, 500);
}
return c.json(result.right, 200);
});

Effect.provide(DbLive) injects the production database. Effect.either converts the failure channel into an inspectable value. PostNotFoundError becomes a 404. DatabaseError becomes a 500.

The contract schemas define these shapes, and the layers make the implementation honor them.

Payoff

You did not write separate documentation. Every route used createRoute, every schema called .openapi(), and the same contract generated the spec. Once the spec exists, you can project it into different representations for humans, generated clients, and language models without maintaining a separate source of truth:

const apiSchema = api.getOpenAPI31Document({
info: { title: "My API", version: "1.0.0" },
openapi: "3.1.1",
});
api.get("/docs", Scalar({ sources: [{ content: apiSchema }] }));
api.get("/openapi.json", (c) => c.json(apiSchema));
api.get("/llms.txt", (c) =>
c.text(await createMarkdownFromOpenApi(JSON.stringify(apiSchema))),
);

Interactive Scalar docs at /docs. A machine-readable spec at /openapi.json. LLM-readable Markdown at /llms.txt. Three endpoints, all generated from the same source. Documentation cannot drift because it was never written separately.

Tests without infrastructure

Swap one layer and your tests run against in-memory SQLite. You don’t need a mocking framework or a running database:

src/posts/posts.repo.test.ts
import { describe, expect, test } from "bun:test";
import { Effect, Either } from "effect";
import { DbTest } from "@/db/client";
import * as postsRepo from "./posts.repo";
describe("postsRepo", () => {
test("returns the post when it exists", async () => {
const result = await Effect.runPromise(
Effect.gen(function* () {
const created = yield* postsRepo.create({
body: "Drizzle derives Zod. Zod drives OpenAPI.",
title: "Schema-Driven APIs",
});
return yield* postsRepo.findById(created.id);
}).pipe(Effect.provide(DbTest)),
);
expect(result.title).toBe("Schema-Driven APIs");
});
test("fails with PostNotFoundError when id does not exist", async () => {
const result = await Effect.runPromise(
postsRepo
.findById("post_does_not_exist")
.pipe(Effect.provide(DbTest), Effect.either),
);
expect(Either.isLeft(result)).toBe(true);
if (Either.isLeft(result)) {
expect(result.left._tag).toBe("PostNotFoundError");
}
});
});

These tests validate domain logic with the same typed dependencies and error model the API uses in production. They don’t need migration files or shared state between runs.

Adding a resource

A new resource follows the same five pieces: errors, schema, routes, repo, and HTTP handler. Register with api.route("/", resource). Each one gets typed errors, schema-driven validation, auto-generated docs, and testable domain logic from the same structure.

Conclusion

Schema-driven design only works when every layer enforces it. The storage schema defines persistence. The contract schema defines what callers see. The route schema defines the HTTP surface. Effect lets the compiler track what your code can fail with and what it depends on. A contract is only real when the implementation cannot quietly drift away from it.

Resources

Questions or feedback? Send me an email.

Last updated on

Back to blog