NestJSが好きだけどきつかったから2週間でWebフレームワーク作った ( ZeltJS )
結論
- NestJSがTS Frameworkの中では好きだけど、時代とともにツラミがだいぶ増えてきたよ
- ツラミを解消するために、2026年ならこうでしょ!というのを詰め込んだNestJS likeなTS Frameworkを作ったよ
- AIつかったら2週間で作れたからみんな「ぼくのかんがえたさいきょうのふれーむわーく」を作るといいよ
NestJSが好き
TypeScriptでバックエンド書くとき、ちゃんとした「フレームワーク」はほとんどないと思っている。 宗教戦争になりそうだが、HonoやExpressは自分にとって優秀な「ライブラリ」であって「フレームワーク」ではない。
PHP出身でLaravel(やその前時代のCodeIgnitorやFuelPHPやCakePHP...なつかしい)とかを触ってる身としては、とてもとても物足りない
そんな中、NestJSは割と「フレームワーク」らしいツールだった。 Controllerの書き方を定義し、Databaseとのつなぎこみ方法を定義し、Documentをみたらほとんどどうしたらいいかが書いてある。こういう「作り方の答え」があって初めて、開発者は本質的な機能開発に集中できる。チームで開発するとき、「この処理どこに書く?」で毎回議論しなくて済む。新しいメンバーが入ってきても、フレームワークの流儀に従えばキャッチアップできる。
だからTSフレームワークの中ではNestJSが好き(というかほぼ選択肢がそれしかない)
でもきつい
ただ、使い込むほど、きつい部分がたくさん襲ってくる
Decoratorが標準じゃない
NestJSのDecoratorはexperimentalDecorators: trueを前提にしていて、tsconfigをいじらないといけない。コレがまず気持ち悪い。しかも仕様バトルに負けて(?)、TC39 Decoratorが標準に今後なっていくため、移行がまっている
ESM対応が微妙
ESMで動かそうとすると、依存パッケージの関係でハマることがある。そろそろESM対応してほしい。
起動が遅い
そこそこなプロジェクトになると起動に時間がかかる。手元のprojectだと30秒ほどかかった。「ちょっと確認したいだけなのに」という場面で、この数秒が地味にストレス。
サーバーレスで使えない
起動時間の問題は、サーバーレス環境でもおなじくおこる。nodeで動かしていたけどサーバーレスでコスト節約したいよねーとかの選択肢が重すぎる
ミドルウェアが独特
NestJSのミドルウェアは、Guard、Interceptor、Pipe、、、なにそれ?Express/Fastifyの文化で形成されてきた(req, res, next) => {}のシグネチャを完全に無視というか、独自路線すぎる。 それゆえ、AsyncLocalStorage使いたいとなるとかなり工夫してhack的なことをしないといけなかった(すれば一応できた)
NestJSしか選択肢がないが、NestJSは実装がモダンTSについてきてない
そういう気持ちがずっとあった。
だから作った
欲しかったのは「NestJSの思想 + モダンTS + どこでも動く」フレームワーク。
無いなら作るしかない。「ぼくのかんがえたさいきょうのふれーむわーく」を作ろう。
...2週間で作れた。
コードの雰囲気
実際のコードはこんな感じ:
import { Controller, Get, Post, pathParam, validated, response } from '@zeltjs/core';
import * as v from 'valibot';
const CreateUserBody = v.object({
name: v.string(),
email: v.pipe(v.string(), v.email()),
});
@Controller('/users')
export class UserController {
constructor(private userService: UserService) {}
@Get('/')
findAll() {
return this.userService.findAll();
}
@Get('/:id')
findOne(id = pathParam('id')) {
return this.userService.findOne(id);
}
@Post('/')
create(body = validated(CreateUserBody), res = response()) {
const user = this.userService.create(body);
return res.json(user, 201);
}
}
NestJSを使ったことがあれば、違和感なく読めると思う。
違いはパラメータの取り方。@Param('id') id: stringではなくid = pathParam('id')。これについては後で詳しく書く。
DIもある。コンストラクタにUserServiceを書けば、自動で注入される。
バリデーションはValibotを使っている。validated()に渡すと、リクエストボディを検証して型付きで返してくれる。失敗したら400エラー。
どこでも動く
Node.js、Bun、Cloudflare Workers、AWS Lambda——同じコードが動く。
const app = createApp({
http: {
controllers: [HelloController],
},
});
// Node.js / Bun
const nodeApp = await onNode(app);
await nodeApp.listen({ port: 3000 });
// Cloudflare Workers
const workersApp = await onCloudflareWorkers(app);
export default { fetch: workersApp.fetch };
アダプターを差し替えるだけ。アプリケーションコードは同じ。裏側ではHonoをつかってるからできること。ありがたやありがたや🔥
ConfigをDIベースに
@Config
class CustomRedisConfig extends RedisConfig {
constructor(private env = injectConfig(EnvConfig)) {
super();
}
override get url(): string {
return this.env.get('REDIS_URL') ?? 'redis://localhost:6379';
}
override get options(): RedisOptions {
return {
maxRetriesPerRequest: 3,
retryStrategy: (times) => Math.min(times * 100, 3000),
};
}
}
configファイルすらDIするようにすることで、継承、拡張がしやすいようにした。
configが別のconfig/serviceを参照、ということもできるように。
TestContainerがConfigReplaceで動く
ConfigをDIベースにしたことで、TestContainerをフレームワークとしてサポートできるようになった
// CustomRedisConfig を RedisTestContainerConfigに置き換え
const runner = onTest(app, { configs:[RedisTestContainerConfig] } )
これにより、 appをapp.tsでexportし、
- 通常利用では
onNode(app)で動かす - E2E testでは
onTest(app, {configs: ...})でinfra mockされた状態で動かす
ができるようになった
本番環境と同じappをテストできる、が個人的こだわりポイント
いろんなフレームワークのいいとこ取り
どこでも動かしたいというのはHonoの思想からもらって取り入れたが、ほかにも
- Configの合成・書き方はLaravelから
- TC39対応可能なDIのinjectの書き方はNeedle-diから
- LoggerのFormatter/translatorの思想はLaravelから
- preGeneratorでのBridgeCode生成による型安全性向上の思想はfrourioから
- createAppでControllerなどを明示列挙するのはrouting-controllerから
など、自分が使ってて好きだった思想をとりこんでいる。なので完全にNestJS互換ではなく「ぼくのかんがえたさいきょうのふれーむわーく」となっている
2週間で作れた理由:AIで設計を探索した
フレームワーク設計は「正解がない選択」の連続で、自分の理想を求めると際限がない。
何処かで言語機能とのバトルが始まり、妥協点を探っていく作業だった。
Decoratorの書き味はどうするか。DIの解決タイミングはいつにするか。Configの受け取り方はどうするか。エラーハンドリングはどう設計するか。
言語機能とのバトルなので、実際に書いてみないと制約が浮かび上がってこないことが多かった。従来なら「1つの案を実装→使ってみて違和感→作り直し」で時間が溶ける。試行錯誤に何週間もかかる。
今回はClaude Codeを使って、このサイクルを高速に回した。
やり方
-
部品のPOCを作る
まず小さい単位で動くものを出してもらう。「Decoratorでメタデータを保存する最小実装」「AsyncLocalStorageでリクエストコンテキストを管理する例」みたいな感じ。
書きたいコードを渡して、これ実現できるようにして、みたいな感じ -
制約とのトレードオフを踏まえ書き心地で選ぶ
部品POCでこの書き方ならこの制約がある、みたいなのが浮き彫りになるので、好きなだけパターンを作って、並べて比較して、自分が一番しっくりくる形を選ぶ。「なんか違う」と思ったら、なぜ違うのか言語化して、別のパターンを試す。 -
組み込む
部品ができたら、それを組み込んでもらう。ここは割とスムーズ。
最終判断は自分。AIは選択肢を高速にPOCするためのツールとして活用した
具体例:パラメータの書き方
コントローラのパラメータをどう書くか。これだけで何パターンも試した。
// 案1: Hono式 - cに全部詰める
@Get('/:id')
findOne(c: Context) {
const id = c.param('id');
const query = c.query('filter');
const user = c.get('currentUser');
// ...
}
Honoと同じ方式。シンプルで分かりやすい。でもDIと相性が悪い。cがなんでも持ってるので、依存関係が見えにくい。テストでモックするのも面倒。
// 案2: 分割式
@Get('/:id')
findOne(params: Params, query: Query, context: Context) {
const id = params.id;
const user = context.currentUser;
// ...
}
引数で分けるパターン。依存が明示的になる。でも引数が増えると破綻する。使わない引数も書かないといけない場面が出てくる。
// 案3(採用): デフォルト引数式
@Get('/:id')
findOne(
id = pathParam('id'),
user = currentUser()
) {
// ...
}
最終的にこの形にした。
デフォルト引数にすることで、使う側は必要なものだけ書けばいいのと、constructorのDIとの親和性も高い。currentUser()みたいなカスタムコンテキストも、関数として定義すれば同じように使える。
currentUser()の型をどうするか、という問題もあった。これはTypeScriptのdeclaration mergingを使って、ユーザーが型定義を拡張できるようにした。
// ユーザーが定義
declare module '@zeltjs/core' {
interface RequestContext {
currentUser: User;
}
}
// 使う側は型安全
findOne(user = currentUser()) {
user.id; // 型が効く
}
authしてないrouteでのcurrentUser()利用は、後々頑張ればlint的にチェックできそうだなというのもここでのポイントだった
こういう「試して→比べて→選ぶ」を、AIで高速に回せた。
そして実コードもほぼAIに書いてもらった。
だから2週間で形になった。
おわりに
まだv0.xなので、breaking changeもあり得る状態だけど、自分が欲しかったものは一通り動くようになった。Controller、Service、DI、バリデーション、認証、ミドルウェア、OpenAPI生成、CLIコマンド...。
よかったら触ってみてほしい & そしてぜひみなさんそれぞれの「ぼくのかんがえたさいきょうのふれーむわーく」をつくってください
2週間で作れるならコスパいいはず
Discussion