Which middleware is the feature for?
@hono/zod-openapi
What is the feature you are proposing?
Hey, sorry. Claude got a little zealous and posted a comment without the ability for me to write a human forward and explain where this came from.
I've converted an almost comically large API surface from express-openapi-validator and spec-first to Hono using @hono/zod-openapi (we have a Swift client!) and Hono RPC, and am moving everything into a monorepo structure, just as part of a general modernization effort (and also since it's worth doing while tokens are cheap). We've been on tsgo and oxlint-tsgolint for a while, and have very inference-heavy Kysely usage, so I figured going all-in on TS inference with Hono wouldn't be that big of a deal. Instead, it made my M1 Air basically cry itself to sleep, took way longer than expected on my Core i9, and required significantly increasing our GHA runner resource class for CI.
So I spent a bunch of time trying to figure out what was going on, since while I was expecting some tradeoff, this seemed orders of magnitude worse than what I expected. I'll let Claude take it from here, but I've got numbers that have been independently verified, and some reproduction repos. I don't think this is necessarily Hono's problem to solve, and I'll also be sharing these results with the tsgo team, but in case this is too architecturally deep to be fixed in time for 7.0 GA I figured I'd post about it here with the amelioration tactic that's been successful for us in case y'all want to update the best-practices guidelines so other people don't run into this same footgun when they upgrade to tsgo and discover that it's somehow slower.
Claude from here:
Some data that may be useful here: the "excessively deep" cost from .openapi() is largely an accumulation effect, and there's a behavior-preserving way to sidestep it.
Building an OpenAPIHono by chaining .openapi(route, handler) accumulates the route-schema generic at every link. For the same routes, handlers, and emitted OpenAPI spec, registering each route on its own single-route app and combining with .route() is dramatically cheaper to type-check.
Type instantiations (tsc --extendedDiagnostics), N=60 routes, realistic schemas — tsc 5.9.3 and @typescript/native-preview (tsgo) 7.0.0-dev:
| variant |
tsc |
tsgo |
chained .openapi() |
4.71M |
4.69M |
.route()-merged |
1.50M |
1.49M |
chained + hc<> client |
5.13M |
9.80M |
merged + hc<> client |
1.74M |
3.23M |
Two takeaways:
- App side:
.route()-merging is ~3× cheaper than chaining, under both compilers.
- Client side: the
hc<> instantiation increment over a chained app is ~+0.4M under tsc but ~+5.1M under tsgo (~12×). As the native port approaches GA, zod-openapi RPC consumers are where this will bite hardest — and the merge cuts it ~3× there too.
(Plain zValidator .get/.post RPC doesn't show this — it's already cheap and merging makes it worse; the effect is specific to .openapi()'s accumulation.)
Workaround — identical routing and OpenAPI output:
export const app = new OpenAPIHono()
.route("/", new OpenAPIHono().openapi(routeA, handlerA))
.route("/", new OpenAPIHono().openapi(routeB, handlerB));
Minimal repro (one command, runs tsc + tsgo per variant): https://github.com/daviduzumeri/hono-zod-openapi-rpc-typecheck-perf
Could .openapi() accumulate via a flatter representation, and/or could the docs recommend the .route()-merge pattern for large RPC apps ahead of tsgo GA?
Which middleware is the feature for?
@hono/zod-openapi
What is the feature you are proposing?
Hey, sorry. Claude got a little zealous and posted a comment without the ability for me to write a human forward and explain where this came from.
I've converted an almost comically large API surface from express-openapi-validator and spec-first to Hono using
@hono/zod-openapi(we have a Swift client!) and Hono RPC, and am moving everything into a monorepo structure, just as part of a general modernization effort (and also since it's worth doing while tokens are cheap). We've been ontsgoandoxlint-tsgolintfor a while, and have very inference-heavy Kysely usage, so I figured going all-in on TS inference with Hono wouldn't be that big of a deal. Instead, it made my M1 Air basically cry itself to sleep, took way longer than expected on my Core i9, and required significantly increasing our GHA runner resource class for CI.So I spent a bunch of time trying to figure out what was going on, since while I was expecting some tradeoff, this seemed orders of magnitude worse than what I expected. I'll let Claude take it from here, but I've got numbers that have been independently verified, and some reproduction repos. I don't think this is necessarily Hono's problem to solve, and I'll also be sharing these results with the tsgo team, but in case this is too architecturally deep to be fixed in time for 7.0 GA I figured I'd post about it here with the amelioration tactic that's been successful for us in case y'all want to update the best-practices guidelines so other people don't run into this same footgun when they upgrade to tsgo and discover that it's somehow slower.
Claude from here:
Some data that may be useful here: the "excessively deep" cost from
.openapi()is largely an accumulation effect, and there's a behavior-preserving way to sidestep it.Building an
OpenAPIHonoby chaining.openapi(route, handler)accumulates the route-schema generic at every link. For the same routes, handlers, and emitted OpenAPI spec, registering each route on its own single-route app and combining with.route()is dramatically cheaper to type-check.Type instantiations (
tsc --extendedDiagnostics), N=60 routes, realistic schemas —tsc 5.9.3and@typescript/native-preview(tsgo)7.0.0-dev:.openapi().route()-mergedhc<>clienthc<>clientTwo takeaways:
.route()-merging is ~3× cheaper than chaining, under both compilers.hc<>instantiation increment over a chained app is ~+0.4M under tsc but ~+5.1M under tsgo (~12×). As the native port approaches GA, zod-openapi RPC consumers are where this will bite hardest — and the merge cuts it ~3× there too.(Plain
zValidator.get/.postRPC doesn't show this — it's already cheap and merging makes it worse; the effect is specific to.openapi()'s accumulation.)Workaround — identical routing and OpenAPI output:
Minimal repro (one command, runs tsc + tsgo per variant): https://github.com/daviduzumeri/hono-zod-openapi-rpc-typecheck-perf
Could
.openapi()accumulate via a flatter representation, and/or could the docs recommend the.route()-merge pattern for large RPC apps ahead of tsgo GA?