きっかけ
「TypeScript API starter」で検索すると 2 種類が見つかる。どちらもちょうどいい場所にいない。
1 つ目は大きすぎる。turborepo、pnpm workspaces、drizzle、pino、OpenTelemetry、Kubernetes デプロイ。ブックマーク API を作りたかっただけなのに、深夜に tsconfig.base.json のチェーンをデバッグしている。
2 つ目は薄すぎる。"hello" を返す index.ts が 1 ファイル。入力バリデーションは?マイグレーションは?テストは?エラーハンドリングは?全部まだ自分で決める必要がある。
欲しかったのは「1 回通して読める規模で、全ての設計判断が見えて、そのままコピーして自分のドメインを載せられるスターター」だった。
作ったもの
ts-api-starter -- Hono + Zod + better-sqlite3 のブックマーク CRUD。
GitHub: https://github.com/sen-ltd/ts-api-starter
- ランタイム依存: Hono / Zod / better-sqlite3 の 3 つだけ
- 23 テスト(vitest)、約 600 行の TypeScript
- 172 MB の Docker イメージ
技術的なポイント
なぜ Hono か
Express のハンドラシグネチャ (req, res, next) は 2010 年の設計で async/await より前。Fastify は JSON Schema + Ajv に縛られる。
Hono のハンドラは Web Fetch API 標準。テストでサーバーを起動する必要がない:
const app = createApp(db);
const res = await app.request('/bookmarks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: 'https://hono.dev', title: 'Hono' }),
});
expect(res.status).toBe(201);
ネットワークなし、supertest なし、ポートの奪い合いなし。23 テスト全てがこのパターン。
Zod による単一バリデーション境界
各ルートハンドラの最初の 1 行で、型なしの入力を型付きドメインオブジェクトに変換する。
app.post('/', async (c) => {
const body = bookmarkCreateSchema.parse(await c.req.json());
const bookmark = insertBookmark(db, body);
return c.json(bookmark, 201);
});
.parse() が通れば body は BookmarkCreate 型。それ以降 SQL クエリまで全て厳密な TypeScript。「このフィールドもうバリデーション済みだっけ?」という疑問が発生しない。
同じスキーマファイルをフロントエンドからも import できる。Zod は isomorphic なので、React フォームと API バックエンドが同一のバリデーションルールを共有可能。
なぜ better-sqlite3 の同期 API か
better-sqlite3 は同期。これはバグではなく特徴。
export function insertBookmark(db: DB, input: BookmarkCreate): Bookmark {
const stmt = db.prepare(
'INSERT INTO bookmarks (url, title, tags, created_at) VALUES (?, ?, ?, ?)',
);
const info = stmt.run(input.url, input.title, JSON.stringify(input.tags), createdAt);
return { id: Number(info.lastInsertRowid), /* ... */ };
}
await なし。コネクションプールなし。トランザクションはクロージャ 1 つ。非同期にすると DB に触る関数が全部 async になり、その呼び出し元も async になり、コードベース全体が async の色に染まる。SQLite の単一ボックスサービスでは、その複雑さに見合わない。
トレードオフは「500ms のクエリがイベントループをブロックする」こと。ブックマーク API のクエリはマイクロ秒単位なので問題にならない。ボトルネックになったら Postgres に乗り換える。Zod のバリデーション境界がきれいなので、変更は src/db/queries.ts だけで済む。
20 行のマイグレータ
migrations/ に SQL ファイルを置くだけ。DSL なし、ダウンマイグレーションなし。ロールバックは「前のマイグレーションを直す新しいマイグレーション」を書く。小さなサービスには、これ以上の仕組みはバグの元。
意図的に入れなかったもの
- 認証 -- 別コミットで追加すべき
- Redis -- キャッシュが必要になってから
- ジョブキュー -- バックグラウンド処理が必要になってから
- pino -- 20 行の
console.log(JSON.stringify(...))で十分
スターターに余計なものを入れると、それを使う全プロジェクトがその依存を背負う。スターターは足し算ではなく引き算。
30 秒で試す
git clone https://github.com/sen-ltd/ts-api-starter
cd ts-api-starter
docker build -t ts-api-starter .
docker run --rm -p 8000:8000 ts-api-starter
curl -sS -X POST http://localhost:8000/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://hono.dev","title":"Hono","tags":["framework","typescript"]}'
curl -sS 'http://localhost:8000/bookmarks?tag=framework' | jq .
おわりに
API スターターの価値は「何が入っているか」ではなく「何が入っていないか」で決まると思う。Hono + Zod + better-sqlite3 は、TypeScript バックエンドの最小構成として自分が一番しっくり来る組み合わせだった。境界がはっきりしていれば、後からどの部品を入れ替えても爆発半径が小さい。
