この記事について
前回のTypeScript基礎編に続いて、今回はHonoとExpressで同じ仕様のTodo APIを実装し、比較します。
- 同じCRUD仕様(一覧取得・登録・完了フラグ更新・削除)
- 同じSQLiteスキーマ(
better-sqlite3) - 同じ
tsconfig.jsonの方針(noUncheckedIndexedAccess/exactOptionalPropertyTypes)
条件を揃えた上で、コード量・レスポンスタイム・DXの違いを実際に動かして比較します。実装中に踏んだバグやハマりどころも、そのまま記事の材料にしています。
1. プロジェクト構成
前回作ったhono-express-nextjs-todo/に、hono/とexpress/のフォルダを追加します。Dockerfileは共通化し、ポートで両者を分けます。
hono-express-nextjs-todo/
├── Dockerfile (hono/express共通)
├── docker-compose.yml
├── hono/
│ ├── src/
│ └── data/
└── express/
├── src/
└── data/
# docker-compose.yml
services:
hono-app:
build: .
volumes:
- ./hono:/app
- hono_node_modules:/app/node_modules
working_dir: /app
ports:
- "3001:3000"
tty: true
express-app:
build: .
volumes:
- ./express:/app
- express_node_modules:/app/node_modules
working_dir: /app
ports:
- "3002:3000"
tty: true
volumes:
hono_node_modules:
express_node_modules:
Honoは3001、Expressは3002でホストに公開します。
2. Hono実装
docker compose exec hono-app sh
npm init -y
npm install hono @hono/node-server better-sqlite3
npm install -D typescript @types/node @types/better-sqlite3 tsx
npx tsc --init
tsconfig.jsonは基礎編と同じ方針(rootDir/outDir/types: ["node"]、jsxは不要なので削除)に直します。
db.ts
import Database from "better-sqlite3";
const db = new Database("./data/todo.db");
db.exec(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
export default db;
SQLite自体には真偽値型が無いので、doneはINTEGER(0/1)で持ち、アプリ側でbooleanに変換します。
index.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import db from "./db.js";
interface TodoRow {
id: number;
title: string;
done: number;
created_at: string;
}
interface Todo {
id: number;
title: string;
done: boolean;
createdAt: string;
}
function toTodo(row: TodoRow): Todo {
return {
id: row.id,
title: row.title,
done: row.done === 1,
createdAt: row.created_at,
};
}
const app = new Hono();
app.get("/todos", (c) => {
const rows = db.prepare("SELECT * FROM todos ORDER BY id").all() as TodoRow[];
return c.json(rows.map(toTodo));
});
app.post("/todos", async (c) => {
const body = await c.req.json<{ title: string }>();
const result = db.prepare("INSERT INTO todos (title) VALUES (?)").run(body.title);
const row = db.prepare("SELECT * FROM todos WHERE id = ?").get(result.lastInsertRowid) as TodoRow;
return c.json(toTodo(row), 201);
});
app.patch("/todos/:id", async (c) => {
const id = c.req.param("id");
const body = await c.req.json<{ done?: boolean }>();
if (body.done !== undefined) {
db.prepare("UPDATE todos SET done = ? WHERE id = ?").run(body.done ? 1 : 0, id);
}
const row = db.prepare("SELECT * FROM todos WHERE id = ?").get(id) as TodoRow | undefined;
if (!row) return c.json({ error: "not found" }, 404);
return c.json(toTodo(row));
});
app.delete("/todos/:id", (c) => {
const id = c.req.param("id");
db.prepare("DELETE FROM todos WHERE id = ?").run(id);
return c.body(null, 204);
});
serve({ fetch: app.fetch, port: 3000 }, (info) => {
console.log(`Hono server is running on port ${info.port}`);
});
実際に踏んだバグ①:lastInsertRowIdのタイポ
最初に書いたコードはresult.lastInsertRowId(IdのIが大文字)になっており、実行時にこうクラッシュしました。
TypeError: Cannot read properties of undefined (reading 'id')
at toTodo (/app/src/index.ts:21:17)
better-sqlite3のRunResultが持つ正しいプロパティ名はlastInsertRowid(すべて小文字)です。存在しないプロパティへのアクセスはundefinedを返すため、get(undefined)で該当行が見つからず、rowがundefinedのままtoTodo(row)に渡されてクラッシュしていました。
これはTypeScriptの型チェック(tsc --noEmit)では検出されませんでした。resultの型推論が緩く、存在しないプロパティへのアクセスを弾いてくれなかったためです。基礎編で扱ったnoUncheckedIndexedAccessの思想(存在しないかもしれないアクセスは型で正直に表現する)が、サードパーティライブラリの型定義次第では効かないケースがある、という実例になりました。
実際に踏んだバグ②:DELETEの戻り値
DELETE /todos/:idの実装で、初めは以下のように書いていました。
app.delete("/todos/:id", (c) => {
const id = c.req.param("id");
db.prepare("DELETE FROM todos WHERE id = ?").run(id);
return c.body(null, 404); // 削除成功のはずなのに404
});
削除に成功しているのに404を返してしまうミスです。正しくは204(No Content、成功したが返す内容がない)です。単純な書き間違いですが、レスポンスコードの意味を確認せずにコピー&ペーストで書き進めるとこうした事故が起きる、という自戒も込めて記録しておきます。
3. Express実装
docker compose exec express-app sh
npm init -y
npm install express better-sqlite3
npm install -D typescript @types/node @types/express @types/better-sqlite3 tsx
npx tsc --init
db.tsはHonoと全く同じ内容です。
index.ts
import express from "express";
import db from "./db.js";
interface TodoRow {
id: number;
title: string;
done: number;
created_at: string;
}
interface Todo {
id: number;
title: string;
done: boolean;
createdAt: string;
}
function toTodo(row: TodoRow): Todo {
return {
id: row.id,
title: row.title,
done: row.done === 1,
createdAt: row.created_at,
};
}
const app = express();
app.use(express.json());
app.get("/todos", (req, res) => {
const rows = db.prepare("SELECT * FROM todos ORDER BY id").all() as TodoRow[];
res.json(rows.map(toTodo));
});
app.post("/todos", (req, res) => {
const { title } = req.body as { title: string };
const result = db.prepare("INSERT INTO todos (title) VALUES (?)").run(title);
const row = db.prepare("SELECT * FROM todos WHERE id = ?").get(result.lastInsertRowid) as TodoRow;
res.status(201).json(toTodo(row));
});
app.patch("/todos/:id", (req, res) => {
const id = req.params.id;
const { done } = req.body as { done?: boolean };
if (done !== undefined) {
db.prepare("UPDATE todos SET done = ? WHERE id = ?").run(done ? 1 : 0, id);
}
const row = db.prepare("SELECT * FROM todos WHERE id = ?").get(id) as TodoRow | undefined;
if (!row) {
res.status(404).json({ error: "not found" });
return;
}
res.json(toTodo(row));
});
app.delete("/todos/:id", (req, res) => {
const id = req.params.id;
db.prepare("DELETE FROM todos WHERE id = ?").run(id);
res.status(204).send();
});
app.listen(3000, () => {
console.log("Express server is running on port 3000");
});
Honoとの違いとして、ルートハンドラの引数がHonoは(c)(Context1つ)なのに対し、Expressは(req, res)(2つ)です。またJSONパースも、Honoはルートごとにc.req.json()を呼ぶのに対し、Expressはapp.use(express.json())をグローバルミドルウェアとして先に登録する必要があります。
実際に踏んだバグ:db.prepare is not a functionの謎
実装中に、以下のエラーに遭遇しました。
TypeError: import_db.default.prepare is not a function
at <anonymous> (/app/src/index.ts:32:19)
db.ts自体を見返しても構文上の問題は見当たらず、原因の切り分けのためdb.tsとindex.tsの両方にデバッグログを仕込みました。
console.log("db type:", typeof db);
console.log("db value:", db);
console.log("db.prepare type:", typeof db?.prepare);
最初にこのエラーが出た時点では:
db type: object
db value: {}
db.prepare type: undefined
dbが空オブジェクト{}になっていました。ところが、db.ts側にも同様のログを仕込んで再実行すると:
[db.ts] db type: object
[db.ts] db instanceof Database: true
[db.ts] db.prepare type: function
db type: object
db value: Database {
name: './data/todo.db',
open: true,
inTransaction: false,
readonly: false,
memory: false
}
db.prepare type: function
今度は正常でした。db.ts単体は常に正しくDatabaseインスタンスを作れているのに、index.tsから見たdbが時々壊れる、という不可解な状態です。
軽量コンテナ(node:24-slim)にはpsコマンドが入っておらず、プロセスの残留状況を直接確認できなかったため、原因の完全な特定はできませんでした。ただし、tsxの再起動を挟む過程で意図しない終了のさせ方(Ctrl+Xの誤入力など)をしていたため、tsxのプロセスが裏で複数残っていて、古いモジュールキャッシュを掴んだままになっていた可能性が高いと考えています。
対処は、コンテナごと再起動してクリーンな状態に戻すことでした。
docker compose restart express-app
再起動後は問題なくdb.prepareが関数として認識され、CRUDが正常に動作しました。原因を100%特定できなかった点は心残りですが、「エラーメッセージだけでは分からない時は、疑わしい層(モジュール、プロセス、キャッシュ)ごとにログを仕込んで切り分ける」という手順自体は再現性のある教訓として残せたと思います。
コードレビューで見つかった追加の不具合
上記のデバッグと並行して、実装したコードには以下の問題も含まれていました。
-
app.post("todos", ...)— ルートパスの先頭スラッシュが抜けている(正しくは"/todos") -
toTodo関数内でdone: row.doneのまま、booleanへの変換(row.done === 1)を忘れている -
db.prepara("DELETE FROM todos WHERE id = ?")—prepareのタイポ
いずれもnpx tsc --noEmitで検出できるものと、実行時まで気づけないものが混在していました。①はExpressのルーティングが単に一致しなくなるだけで型エラーにはならず、②はTodoインターフェースのdone: booleanと矛盾するため型エラーになり、③は存在しないメソッド名として実行時エラーになります。
4. TypeScript ESM特有の罠:.jsでimportする
Hono・Express両方の実装で共通して引っかかったのが、nodenextモジュール解決での import 記法です。
// ファイルの実体は db.ts なのに
import db from "./db.js"; // importでは .js と書く
TypeScriptはコンパイル後に.tsが.jsになることを前提にしており、nodenextモードではNode.jsのESM実行時ルール(相対importで拡張子省略不可)に合わせて、先読みで.jsと書く必要があります。PHPのrequire(拡張子込みで実ファイルパスをそのまま書く)とは発想が逆で、最初は違和感がありました。
5. 動作確認でハマった話:PowerShellの文字化け
curl相当のコマンドでAPIの動作確認をする際、日本語を含むデータで思わぬ沼にハマりました。
Invoke-RestMethod -Uri "http://localhost:3001/todos" -Method Post -ContentType "application/json" -Body '{"title":"牛乳を買う"}'
これを実行すると、titleが?????(文字化け)としてDBに保存されてしまいました。原因は$PSVersionTable.PSVersionで確認したWindows PowerShell 5.1(pwsh.exeではなくpowershell.exe)の日本語エンコーディング周りの弱さでした。
Invoke-RestMethod -Bodyに生の文字列を渡すと、PowerShell 5.1はリクエストボディをASCII/ANSI系のエンコーディングで送ろうとし、表現できない日本語文字を?に潰してしまいます。[System.Text.Encoding]::UTF8.GetBytes(...)で明示的にUTF-8バイト列に変換して送っても、今度はレスポンス側のデコードで別の文字化けパターン(çä¹³ãè²·ãのような、UTF-8のバイト列をLatin-1として誤解釈した形)が発生しました。
ちなみにHono側のc.json()はデフォルトでContent-Type: application/json; charset=UTF-8を返す仕様になっており、サーバー側は最初から正しくUTF-8を宣言していました。問題はクライアント(PowerShell 5.1)側の解釈にありました。
最終的な対処は、Invoke-RestMethod/Invoke-WebRequestを諦めて、Windowsに標準搭載されている本物のcurl.exe(curlという名前で登録されているInvoke-WebRequestのエイリアスとは別物)を使うことでした。日本語を含むボディは、いったんUTF-8のファイルに書き出してから渡します。
'{"title":"牛乳を買う"}' | Out-File -FilePath body.json -Encoding utf8 -NoNewline
curl.exe -X POST http://localhost:3001/todos -H "Content-Type: application/json" --data-binary "@body.json"
{"id":5,"title":"牛乳を買う","done":false,"createdAt":"2026-09-12 08:13:12"}
これで文字化けせずに登録・取得できることを確認しました。
6. HonoとExpressの比較
同じ仕様のCRUD APIが両方揃ったので、実際に比較します。
コード行数
| index.ts | db.ts | 合計 | |
|---|---|---|---|
| Hono | 60 | 13 | 73行 |
| Express | 63 | 14 | 77行 |
同じ機能で、Honoの方が約5%少ない行数で書けました。c.req.json()をルートごとに呼ぶだけで済む点や、Contextオブジェクト経由でレスポンスを組み立てる点がコンパクトさに寄与していそうです。
なお、Express側はdb.prepareバグの調査中に仕込んだデバッグログ(console.log)が計測時に混入しており、一度は88行という数値が出ていました。ログを取り除いた正確な行数が77行です。デバッグ用に残したコードをそのまま計測に使ってしまう、というのもよくある事故なので、記録として残しておきます。
レスポンスタイム(GET /todos、10回試行)
| 全10回平均 | 初回除く9回平均 | 最小 | 最大 | |
|---|---|---|---|---|
| Hono | 7.00ms | 5.96ms | 4.50ms | 16.38ms |
| Express | 5.83ms | 5.61ms | 4.84ms | 7.78ms |
両者とも初回だけやや遅い傾向はあるものの(SQLiteのファイルオープンなどのコールドスタートと思われます)、2回目以降は5〜6ms程度でほぼ横並びでした。ローカルのTodo API・SQLite・この程度の試行回数では、体感できるほどの差は出ませんでした。Express側の初回計測(デバッグログ入り)はconsole.logのI/Oコストが乗っていたためか若干遅めの数値が出ていましたが、ログ除去後はHono側と遜色ない結果になっています。
所感
- コード量:Honoがやや少ないが、5%程度の差にとどまった。「劇的にコンパクト」というほどではない
- レスポンスタイム:このスケールでは誤差の範囲。「Honoが速い」と煽るのは実測に基づかない誇張になる
-
DX:Expressは
(req, res)という馴染みのある形なので、Node.jsのHTTPサーバーに慣れていれば書きやすい。HonoはContext経由で一貫した書き方になるので、型補完の効きやすさは良い印象だった - エラーの起きやすさ:今回のセッションでは、Express実装の方がタイポ・書き漏れが多く発生した(単純に実装順が2番目でペースが早くなっていた可能性もあり、フレームワーク自体の欠陥ではない)
まとめ
- 同じ仕様のTodo APIをHono/Expressそれぞれで実装し、コード行数は実測でHonoが約5%少なかった(デバッグログを含めた誤測定を一度経験し、再計測で修正)
- レスポンスタイムはこの規模では有意な差が出ず、「軽量だから速い」を単純に実感できるものではなかった
-
lastInsertRowidのケース(タイポが型チェックをすり抜ける)のように、サードパーティライブラリの型定義の粒度次第ではTypeScriptの安全性にも限界がある - 原因を完全に特定できないバグ(
tsxのモジュールキャッシュ疑惑)にも遭遇したが、疑わしい層ごとにログを仕込んで切り分ける手順自体は再現性のある教訓になった - PowerShell 5.1の日本語エンコーディング問題は、
curl.exeをファイル経由で使うことで回避できた
次回はNext.jsでSPAのフロントエンドを作り、今回のAPIと結合します。


