Skip to content

Why write an HTTP framework in TypeScript in 2026?

In September, Jarred Sumner, who builds Bun, wrote that “JavaScript was never designed for the server”, and that unless the engine changes, Rust will replace it there. Many replies agreed and said to let server-side JavaScript die. A week later Ryan Dahl, who created Node.js and Deno and writes Rust as his main language, answered that application code belongs in JavaScript or TypeScript.

I had just written Tetsu, an HTTP framework for Bun, in TypeScript, and I’d been asking myself the same question while building it. Here’s my answer. It starts with what Go and Rust do better, because they do a lot better.

TypeScript Go Rust
Code with a type error runs: Bun and Node don’t check types doesn’t build doesn’t build
A value matches its type not always: any, as and JSON.parse aren’t checked yes, except with unsafe or a data race yes, except with unsafe
JSON with a missing field passes, typed as whatever you claimed the field quietly gets its zero value serde refuses it, unless it’s optional
Extra fields in a response sent, unless something strips them only the struct’s fields are sent only the struct’s fields are sent
Errors anything can throw, and the type doesn’t say that a call can fail is in the signature Result<T, E> in the signature
Numbers number is a float: 64-bit ids need bigint sized integers sized integers
CPU-heavy work one thread per process all cores all cores
Dependencies npm runs install scripts, Bun only for an allow list fetching or building runs no code build scripts run at build time

TypeScript’s types are erased before the code runs, so they’re claims, and Bun or Node will run code whose claims don’t hold:

const
const order: Order
order
= (await
const res: Response
res
.json()) as Order; // compiles, and nothing checked it
JSON.parse('{"id": 9007199254740993}').id; // 9007199254740992

Rust is stricter almost everywhere. Its compiler refuses whole classes of bugs, and sqlx can even check each SQL query against a real database at build time. Go is simpler and has a careful supply chain, while npm runs install scripts, which is how a worm spread through more than 500 packages in 2025. If your team writes Go or Rust, these are good reasons to stay there.

Then why TypeScript? Because of who reviews the code.

Agents write a lot of our code now, but someone still reviews it before it goes to production, and has to understand it. Tetsu was built that way: Claude wrote most of its code, and I reviewed every change. José Valim, who created Elixir, wrote this week that because of coding agents he reviews significantly more code than before. And most of what that review is for isn’t a type error. In Stack Overflow’s 2025 survey, the most common frustration with AI tools was output that is almost right, and 45% of developers said debugging AI-generated code takes them longer.

Code that is almost right is exactly what a compiler can’t see: a refund that can exceed the order, a permission check on the wrong field, a status that lets a cancelled order ship. Rust’s compiler doesn’t know your business rules either.

Cloudflare learned that in November 2025, when a large part of the web returned errors for about three hours. A database permissions change made a query return duplicate rows, the file that feeds Cloudflare’s bot detection doubled in size, and it went over a limit of 200 features in the new proxy, which is written in Rust. Rust had made the failure visible: it was an error value the code had to handle. The code called unwrap() on it, and the proxy panicked. The compiler did its job. No compiler can tell you that a file which usually holds about 60 features will one day hold more than 200. Someone who knows the system can.

Bun’s own team just showed what a strict compiler is best at: they ported Bun from Zig to Rust with Claude Code so that use-after-free and double-free bugs would become compile errors. Those are memory bugs, and application code on a garbage-collected runtime doesn’t have them. Its expensive bugs are in the business logic, and those are caught by tests and by a person who knows the business and reads the code fluently.

You read fluently in the language you already write. If your team writes Go, that’s Go, and you should pick Go. If it’s a web team that writes TypeScript every day, and its front end too, it’s TypeScript. A model can write Rust for a team that doesn’t know Rust, but then nobody on the team can really review it.

So for a TypeScript team, the useful question is how to make TypeScript code easy to review, and as safe as it can get.

Reviewing Tetsu’s code made one thing clear to me: code is easy to review when everything that runs is visible where you read it. Every language has ways to hide it. Rust’s derive and attribute macros generate code you never see. The compiler checks it, but it isn’t the code you review, and reading it takes cargo expand. Go reads struct tags, which are plain strings, through reflection. TypeScript has decorators and DI containers, which decide at runtime what runs and what gets injected.

How much of that a project uses is the framework’s choice more than the language’s. Tetsu uses none of it. It does use something TypeScript does better than Go, and without the macros Rust needs: types computed from your own code, such as a route’s parameters from its path, or a request body’s type from its schema.

A route in Tetsu is one object: the method, the path, schemas for each part of the request and response, the hooks that run around it, and the handler.

const auth = hook.beforeParse(async (ctx) => {
const
const user: User | undefined
user
= await
const sessions: {
verify(token: string | null): Promise<User | undefined>;
}
sessions
.verify(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
headers: Headers

The headers read-only property of the with the request.

MDN Reference

headers
.get("authorization"));
if (!
const user: User | undefined
user
) throw new HttpError(401);
return {
user: User
user
};
});
const refund = route({
method: "POST"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "POST",
path: "/orders/:id/refund"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/orders/:id/refund",
schema: {
params: z.object({ id: z.coerce.number() }),
body: z.object({ amount: z.number().positive() }),
response: { 200: RefundDto },
},
hooks: { beforeParse: [auth] },
handler: (ctx: {
user: User;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<200>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
readonly body: {
amount: number;
};
}) => Refund

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
user: User;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<200>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
readonly body: {
amount: number;
};
}
ctx
) =>
const refunds: {
create(by: User, orderId: number, amount: number): Refund;
}
refunds
.create(
ctx: {
user: User;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<200>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
readonly body: {
amount: number;
};
}
ctx
.
user: User
user
,
ctx: {
user: User;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<200>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
readonly body: {
amount: number;
};
}
ctx
.
params: {
id: number;
}
params
.
id: number
id
,
ctx: {
user: User;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<200>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
readonly body: {
amount: number;
};
}
ctx
.
body: {
amount: number;
}
body
.
amount: number
amount
),
});

A reviewer sees who can call it, what it accepts, what it returns and what it does. That’s also what lets them see what the route doesn’t do: nothing here stops a refund larger than the order, unless refunds.create checks it. That’s the almost-right bug from earlier. Neither the compiler nor the schema can see it, but a reviewer with the whole route in front of them can ask.

Hooks sit in named slots that always run in the same order, so there’s no next() chain to trace. Anything else that runs for this route sits on its group or the app, in the same form. There are no decorators, no container and no plugins: a controller gets its dependencies as function arguments, and the wiring is ordinary code in one file.

ctx is never annotated. A field is in its type exactly when it exists at that point: ctx.params.id because the path has :id, a number because the schema converts it, ctx.body because the route declares a body, ctx.user because auth returned it. Remove auth, and the handler stops compiling:

route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/reports"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/reports",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => string[]

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
) => reportsFor(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.user),
Error ts(2339) ― Property 'user' does not exist on type '{ readonly req: Request & { readonly cookies?: CookieMap | undefined; }; readonly server: Server<unknown>; readonly out: Outgoing; readonly route: RouteInfo; readonly startedAt: number; readonly params: {}; }'.
});

A reviewer doesn’t have to check by eye whether auth ran before this handler. The compiler already did.

The schemas are the checks. A request with a missing or malformed field gets a 422 that lists every issue, and the handler never runs. This is the old advice to parse, don’t validate: one schema at the edge instead of checks spread through the handler, and one place for a reviewer to look.

Responses are parsed too, which closes one of the gaps from the table. TypeScript lets an object with extra fields pass for a type with fewer, so a stored user with a passwordHash passes for a public user. The response schema runs on the way out, and its output is what gets sent. With Zod or Valibot, that drops the hash. ArkType and TypeBox have to be told to:

const PublicUser = z.object({ id: z.number(), name: z.string() });
route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/users/:id"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/users/:id",
schema: { params: z.object({ id: z.coerce.number() }), response: { 200: PublicUser } },
handler: (ctx: {
readonly out: Outgoing & DeclaredOutgoing<200>;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}) => StoredUser

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly out: Outgoing & DeclaredOutgoing<200>;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}
ctx
) =>
const users: {
find(id: number): StoredUser;
}
users
.find(
ctx: {
readonly out: Outgoing & DeclaredOutgoing<200>;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}
ctx
.
params: {
id: number;
}
params
.
id: number
id
), // a stored user, passwordHash included
});
// GET /users/1 → 200 {"id":1,"name":"Ada"}

The OpenAPI document is built from the same routes and schemas, so a reviewer who approves a schema change has approved the docs too. Tetsu’s own documentation is written for agents as well as people: every page is also served as Markdown, all of it is in one file at /llms-full.txt, and the packages ship TSDoc that editors and agents read from node_modules. An agent that reads the right docs writes code that’s closer to right, which leaves less for the reviewer.

Both are good, and both infer types from the path and from schemas. Where they differ from Tetsu is how the code around a handler is put together, and that’s what a reviewer reads. In Hono, middleware wraps the handler with next(), and what runs for a route depends on the order of the app.use() calls. In Elysia, the app is a chain of method calls, each extending the type of the next, and a plugin’s scope decides how far its hooks reach. Both are more flexible than Tetsu.

Tetsu trades that flexibility for one shape: hooks in fixed slots, listed on the route, its group or the app, and no plugins. It costs more typing, since each route lists its own hooks, and it gives up things the others do well. Hono runs on almost any runtime, and Elysia’s Eden gives the front end the server’s types directly. Comparison has the details.

A framework can make TypeScript safer at the HTTP boundary, but not stricter everywhere. Errors still aren’t in any signature, numbers are still floats, CPU-heavy work still needs a worker, and a database row is still whatever the driver returned unless you parse it too. Tetsu has a limit of its own: hooks mounted on a group run for its routes but don’t add to their types, so a handler that reads ctx.user needs auth on its own route.

The first row of the table is closing. Bun is adding a type checker of its own, bun check, ported from typescript-go to Rust and already in canary builds. bun --check server.ts checks the code before running it, and bun test --check checks it before the tests, so code with a type error no longer has to run at all. I tried the canary build on Tetsu’s repository: on the same 1,588 files, it reported the same errors as tsc, with the same messages, in 0.22 seconds against 0.8 for TypeScript 7, on an M5 Pro. The TypeScript compiler itself was rewritten in Go, and TypeScript 7 came out in July about ten times faster. For a team that reviews TypeScript, that means the type check can run on every save and every test run, not only in CI.

There’s money behind it too. Bun had no revenue when Anthropic bought it in December 2025. Claude Code ships to millions of users as a Bun executable, so Anthropic has a direct stake in Bun’s quality, and Bun’s own post about the deal says its roadmap still includes replacing Node.js as the default runtime on the server. Since then, Bun 1.4 fixed more than 2,900 issues, the runtime itself was rewritten in Rust, and the bun package on npm had 15 million downloads in September 2026, ten times more than a year before. I have no ties to Anthropic, for the record.

Tetsu runs only on Bun, so if Bun stalls, so does Tetsu. All of this lowers that risk without removing it.

If your team writes Go or Rust, pick Go or Rust, for the same reason I picked TypeScript: you’ll review that code best.

If your team writes TypeScript, especially on the front end, TypeScript on the server is a reasonable choice in 2026, if you do four things:

  • Run tsc --noEmit in CI, or bun check once it’s in a stable Bun release.
  • Turn on strict and noUncheckedIndexedAccess, and ban any in the linter, for example with Biome’s noExplicitAny.
  • Parse every boundary with a schema: requests, responses, database rows, other services’ replies and configuration.
  • Choose tools that keep the code readable: no hidden wiring, nothing that runs without being visible where you read.

That’s why I wrote Tetsu in TypeScript. Agents can write the code, but someone still has to understand it, and the language you understand best is the one you already write.

Tetsu is MIT licensed. The quick start builds a first app with a test, and the code is on GitHub.