This is an experiment, not a production framework. Ma exists to explore what best-in-class DX and type safety look like in a TypeScript HTTP framework. It is not published, has no ecosystem, and nobody should depend on it. If you're looking for something to ship with, use Hono or Elysia.
A TypeScript-first HTTP framework for Bun and Deno. The name is the Japanese concept of negative space β a framework that gets out of your way.
Ma prioritizes correct type inference above all else. No internal any, no mangled types, no inference bugs. Every design decision exists to make TypeScript work for you rather than against you. "End to end" means exactly that: the type you return from a handler is the type the client's data field has, across the network, without codegen, without sharing anything but typeof app.
import { Ma, mc } from "@ma/core"
import { z } from "zod"
const app = new Ma()
.get("/hello/:name", (c) => ({ message: `Hello, ${c.params.name}!` }))
.post("/users", { body: z.object({ name: z.string() }) }, (c) => ({
id: 1,
name: c.body.name, // string β validated and typed
}))
Bun.serve({ port: 3000, fetch: app.fetch })
// Elsewhere β a fully typed client, no codegen:
const client = mc<typeof app>("http://localhost:3000")
const res = await client.hello[":name"].get({ name: "world" })
if (res.ok) res.data.message // string- Correct type inference, end to end. From route definition through validation to the RPC client. Params are typed from the path literal alone; schemas add validation, not ceremony. The compile-time test suite (
tests/types.test.ts) is the contract β if a change breaks inference, it does not merge. - Bring your own schema library. Anything implementing Standard Schema works: zod, valibot, arktype, or a five-line hand-rolled object. Ma never imports a validator; it only calls
schema["~standard"].validate(). Consequence: zero runtime dependencies (the@standard-schema/specpackage is types-only). - Web standards are the framework.
Requestin,Responseout. The app is a fetch handler; there are no adapters, no server abstraction, no build step, and no custom request/response wrappers to learn. Anything Ma doesn't do, the platform does. - Honest safety. No internal
anyβ casts exist only at the single boundary where typed handlers are stored in erased form. Client responses admit failure in their types:datais only your route's output type whenokistrue. A type that lies is worse than no type.
- Trying it
- Serving
- The request lifecycle
- Routing
- Validation
- Context
- Response handling
- Typed errors
- Guards
- Plugins
- Route groups
- Hooks
- Typed client
- Test client
- Cookies
- OpenAPI generation
- Type utilities
- Limitations and non-goals
- Architecture
- Development
- Examples
Ma is not on npm and won't be. Clone it and poke at it:
git clone <this repo> && cd ma
bun install
bun test # 85 behavioral + compile-time tests
bun run examples/basic.ts # then curl localhost:3000/users
bun run examples/typed-errors.ts # self-contained, no serverEverything imports from @ma/core, which resolves to src/ via the package exports map β raw TypeScript, no build step. Bun and Deno both execute it directly.
app.fetch is a plain web-standard fetch handler β (req: Request) => Promise<Response>, bound to the app so you can pass it around freely. That is the entire deployment story:
// Bun
Bun.serve({ port: 3000, fetch: app.fetch })
// Deno
Deno.serve(app.fetch)
// Bun also auto-serves a default export with a fetch method
export default app
// Or call it yourself β useful for tests, queues, anything
const res = await app.fetch(new Request("http://x/users"))There are no adapters because there is nothing to adapt: Ma never touches a socket, a port, or a runtime API. If your platform can hand you a Request and take back a Response β Cloudflare Workers, service workers, a unit test β the app runs there. The Deno typecheck config enforces this: src/ compiles with zero Bun types.
One request flows through these steps, in this order. Knowing the order answers most "why didn't X run?" questions:
- Route match. Method + pathname against the router. No match β your
notFoundhandler (or a plain 404). Nothing else runs for unmatched requests except derives fornotFound's context. - Input parsing. Path params are percent-decoded. The query string becomes
Record<string, string>(for repeated keys, the last value wins). The body is parsed by content type β see Validation. Malformed JSON stops here with a 400. - Validation.
params, thenquery, thenbodyβ first failure returns the structured 400 and nothing further runs. Validated values replace the raw ones. - Context construction. Decorations are copied in as the initial state.
- Derives. Run in registration order; each result is merged into the context state.
onRequesthooks. In registration order. Returning aResponseshort-circuits to step 9.- Guards. The guards captured at this route's registration, in order. Returning a
Response(or throwing) short-circuits. - The handler. Its return value is coerced to a
Response(rules). - Cookies. Anything queued via
c.setCookie()is appended β this happens even when the handler returned its ownResponse, and even on the error path. onResponsehooks. Observation only; the response is already decided.
Anything thrown in steps 5β8 goes to onError resolution: your hooks first, then HttpError.toResponse(), then a plain 500.
All seven methods β get, post, put, delete, patch, head, options β each with two call shapes:
const app = new Ma()
// (path, handler) β output type inferred from the return value
.get("/ping", () => ({ pong: true }))
// (path, schemas, handler) β validates input, pins output
.post(
"/users",
{
body: z.object({ name: z.string() }),
response: z.object({ id: z.number(), name: z.string() }),
},
(c) => ({ id: 1, name: c.body.name }),
)Both shapes register into the same route map, so the client sees them identically. Chaining matters: each call returns a new type accumulating the route, which is what makes typeof app carry the whole API.
Params are typed from the path literal β no schema needed. "/users/:id/posts/:postId" alone gives c.params the type { id: string; postId: string }:
.get("/users/:id/posts/:postId", (c) => {
c.params.id // string
c.params.postId // string
})A params schema is for validation and coercion, not for typing β add one only when you want c.params.id to be a number, or to reject bad input early.
A trailing * captures the rest of the path under c.params["*"]:
.get("/files/*", (c) => c.params["*"]) // "a/b/c.txt" for GET /files/a/b/c.txt* is only valid as the final segment; Ma throws at registration time otherwise.
The router is a segment trie with backtracking. The rules, in full:
- Precedence: static segments beat
:paramsbeat trailing*β at every position, not just globally. - Backtracking: if a more-specific branch dead-ends, matching falls back.
/users/all/countand/users/:id/postscoexist:GET /users/all/postscorrectly matches the param route withid: "all". - Params never match empty segments.
GET /users/does not match/users/:idβ trailing slashes are meaningful, and//in a path matches nothing. - Different param names at the same position (
/a/:x/left,/a/:y/right) both work; each route captures under its own name. - Duplicate registration of the same method + path silently overwrites β last one wins. Don't do that.
Any Standard Schema can validate three request sources: params, query, and body. Validation runs before your handler, in that order, stopping at the first failure. Crucially, the validated value replaces the raw input β whatever your schema outputs is what the handler receives, so defaults, coercions, and transforms all apply:
.get("/items/:id", {
params: z.object({ id: z.coerce.number() }), // c.params.id is number
query: z.object({ page: z.coerce.number().default(1) }), // missing ?page β 1
}, (c) => ({ id: c.params.id, page: c.query.page }))Some ground truths about the raw inputs your schemas see:
- Path params and query values are strings. HTTP gives you nothing else. Use your library's coercion (
z.coerce.number()) rather than declaringz.number()and wondering why nothing validates. - Repeated query keys collapse to the last value.
?tag=a&tag=bβ{ tag: "b" }. If you need multi-value queries, readc.url.searchParamsyourself. - The body is parsed by content type before validation:
application/jsonβJSON.parse(malformed JSON is a 400 before your schema is consulted)application/x-www-form-urlencodedandmultipart/form-dataβ an object; repeated field names become arrays- anything else β
c.bodyisnull; readc.reqdirectly for raw bytes or streams GETandHEADrequests skip body parsing entirely
- Async schemas work.
validate()results are awaited, so refinements that hit a database are fine β just remember they run on every request to that route.
The client's body argument is typed as the schema input; the handler's c.body as the schema output. A transform is therefore visible on both sides, correctly:
.post("/measure", { body: z.object({ word: z.string().transform((s) => s.length) }) }, (c) => {
c.body.word // number β the transform already ran
})
// client-side:
await client.measure.post({ body: { word: "hello" } }) // { word: string } β pre-transformA structured 400, always the same shape, pointing at exactly what failed:
{
"error": "Validation failed",
"source": "body",
"issues": [{ "path": "/name", "message": "Invalid input: expected string, received number" }]
}source is whichever of params/query/body failed first; path is a /-joined key path into that source.
A Standard Schema is just an object with a ~standard property. This is the entire integration surface, and it's why Ma has no dependencies:
const evenNumber = {
"~standard": {
version: 1 as const,
vendor: "me",
validate: (v: unknown) =>
typeof v === "number" && v % 2 === 0
? { value: v }
: { issues: [{ message: "expected an even number" }] },
},
}.post("/even", { body: evenNumber }, (c) => c.body) // typed from the schema's genericsresponse pins the route's output type β the handler must return something assignable to the schema's output, c.json() of it, or a typed error. It also feeds OpenAPI. What it deliberately does not do is validate or transform at runtime: responses are your code's output, already covered by the type checker, and re-validating every response would be paying twice for the same guarantee. Declare the wire format you actually produce.
The context is the only argument handlers receive. Everything on it is either parsed input, accumulated state, or a response helper:
;(c) => {
c.params // typed from path or params schema
c.query // typed from query schema (Record<string, string> otherwise)
c.body // typed from body schema (unknown otherwise)
c.req // the raw Request β headers, streams, everything the platform gives you
c.url // parsed URL (searchParams, host, ...)
c.method // "GET", "POST", ...
c.path // url.pathname
c.header("authorization") // string | null β shorthand for c.req.headers.get()
}Runs a function on every request and merges its (possibly awaited) result into the context, fully typed. Derives are the mechanism for per-request state: auth, loaded resources, request IDs.
new Ma()
.derive((c) => ({ user: getUser(c.header("authorization")) }))
.derive((c) => ({ posts: loadPosts(c.get("user")) })) // layered β sees user
.get("/posts", (c) => c.get("posts"))Semantics worth knowing:
- Derives run in registration order, each seeing the state of the previous ones.
- Like guards, a route captures the derives registered before it. The type system already enforces this β routes registered earlier don't see later keys β and the runtime now agrees exactly.
- Throwing inside a derive routes to
onErrorlike any handler error. ThrowingHttpError(401)from an auth derive is a perfectly good pattern.
Sets a value once, at startup, with zero per-request cost. Use it for singletons β a database handle, a config object, a version string:
.decorate("db", new Database())
.get("/users", (c) => c.get("db").allUsers())The decorated reference is shared across requests (that's the point). Per-request state belongs in .derive().
c.get(key) is typed against everything accumulated so far β decorations, derives, guard narrowings. c.set(key, value) exists as an untyped escape hatch for request-scoped scratch data; if you find yourself reaching for it, a derive is usually the better answer.
Handlers return values; Ma turns them into Responses:
| Return value | Response |
|---|---|
Response |
passed through untouched |
null / undefined |
204 No Content |
string |
200, text/plain; charset=utf-8 |
| anything else | 200, application/json |
Note what's not in that table: no status-code-from-number magic (returning 42 gives you JSON 42, as it should), no special-casing of buffers. When you need control over status, headers, or content type, say so explicitly:
.get("/made", (c) => c.json({ id: 1 }, { status: 201, headers: { "x-total": "42" } }))
.get("/page", (c) => c.html("<h1>hi</h1>"))
.get("/moved", (c) => c.redirect("/here", 301))
.get("/bytes", () => new Response(bytes, { headers: { "content-type": "application/octet-stream" } }))c.json(), c.text(), and c.html() return branded responses β a TypedResponse<T> that carries the body type as a phantom. That's how (c) => c.json(users) still produces a precisely-typed client, while a bare new Response(...) types the route output as unknown (Ma can't know what's in it, and won't pretend to).
There are three ways to fail, in decreasing order of type safety:
1. c.error(status, data) β declared, checked. Declare error payload schemas per status; c.error then only accepts declared statuses, and the payload is type-checked against that status's schema. You cannot return a 404 body that doesn't match your 404 schema β this is the part most frameworks leave unchecked:
.get("/users/:id", {
response: z.object({ id: z.string(), name: z.string() }),
errors: {
404: z.object({ message: z.string() }),
403: z.object({ message: z.string(), requiredRole: z.string() }),
},
}, (c) => {
const user = users.get(c.params.id)
if (!user) return c.error(404, { message: "no such user" })
if (user.private) return c.error(403, { message: "forbidden", requiredRole: "admin" })
return user
})c.error returns (rather than throws) so control flow stays visible, and its return type is excluded from the route's success output automatically.
2. HttpError β thrown, untyped. For out-of-band failures from anywhere: handlers, guards, derives, plugins. The body can be nothing, a string, or JSON:
throw new HttpError(401) // 401, "HTTP 401" text body
throw new HttpError(403, "no entry") // 403, text body
throw new HttpError(422, { reason: "bad state" }) // 422, JSON body3. A raw Response. Always available, never typed. Guards returning Response use this.
Routes with declared errors produce a discriminated union β narrow by ok, then by status, and data follows along:
const res = await client.users[":id"].get({ id: "1" })
if (res.ok)
res.data // { id: string; name: string }
else if (res.status === 404)
res.data // { message: string }
else res.data // { message: string; requiredRole: string }An honest caveat: the failure union is closed over the declared statuses. If a route declares only a 404 and something throws a 500 at runtime, the response object will have status: 500 β which the type, having narrowed failure to status: 404, does not admit. This is a deliberate trade: adding a catch-all { status: number; data: unknown } arm would destroy narrowing for the statuses you did declare (every res.status === 404 check would yield data: unknown). Declare the errors your route actually produces, and treat undeclared failures as the bugs they are.
Routes without declared errors return the honest two-arm union: { ok: true; data: YourType } or { ok: false; data: unknown }. Ma will not tell you a 500 body is a User.
.guard<TNarrow>(fn) does two things at once: runs a check on every request, and narrows the context type for every route registered after it. The type parameter is your claim about what the check establishes; the function is the proof. Keep them in sync β Ma trusts you here, the same way a user-defined type guard's is clause trusts you:
new Ma()
.derive((c) => ({ user: getUser(c) })) // user: User | null
.get("/public", handler) // registered before β unaffected, unguarded
.guard<{ user: User }>((c) => {
if (!c.get("user")) throw new HttpError(401)
})
.get("/me", (c) => c.get("user").name) // User β no cast, no `!`Semantics:
- Scoping is positional. A guard captures at route registration; routes above it never run it, routes below it always do. This mirrors the type flow exactly.
- Short-circuit by throwing (usually
HttpError) or by returning aResponse. Returningundefined/voidmeans "pass". - Guards stack. Several guards run in registration order, each seeing state from derives and earlier guards.
- Groups inherit the guards active at the
.group()call, and guards created inside a group apply only within it. .guard()without a type argument is purely a runtime check β the context type is unchanged.
A plugin is any function from app to app. No plugin API, no registration ceremony β function application. Whatever the function adds (derives, guards, routes, decorations), .use() carries its types through:
function auth<TCtx extends ContextState, TRoutes extends RouteMap>(app: Ma<TCtx, TRoutes>) {
return app
.derive((c) => ({ user: getUser(c) }))
.guard<{ user: User }>((c) => {
if (!c.get("user")) throw new HttpError(401)
})
}
new Ma().use(auth).get("/me", (c) => c.get("user")) // typed β auth's context flows throughThe generic signature is the pattern to copy: constraining over TCtx and TRoutes keeps the plugin applicable at any point in any chain, and position matters exactly like it does for .derive() and .guard() β a plugin's guards protect only the routes registered after .use().
.group(prefix, fn) registers a batch of routes under a path prefix. Groups nest, inherit the guards and derives active at the call site, and their route types merge back into the parent β the client sees the full prefixed paths:
app.group("/api/v1", (g) =>
g
.get("/users", handler) // GET /api/v1/users
.group(
"/admin",
(a) => a.get("/stats", handler), // GET /api/v1/admin/stats
),
)Add derives and guards before the group if the group's routes need them; add them inside the group callback to scope them to the group alone.
Four hooks, not a pipeline. Each has one job:
app
.onRequest((c) => {
// Before every matched route's handler (after validation and derives).
// Return a Response to short-circuit β rate limiting, maintenance mode.
if (banned(c)) return new Response(null, { status: 403 })
})
.onError((c, error) => {
// Anything thrown by derives, guards, or handlers lands here first.
// Return a Response to take over; return nothing to decline.
if (error instanceof DbConflict) return Response.json({ retry: true }, { status: 409 })
})
.onResponse((c, res) => {
// Observation only β the response is already decided. Logging, metrics.
console.log(c.method, c.path, res.status)
})
.notFound((c) => ({ error: "not found", path: c.path }))The error resolution order is: your onError hooks in registration order β if none returned a Response and the error is an HttpError, its own response β otherwise a plain-text 500. The thrown error is never serialized into the response by default; leaking exception details is an opt-in you write yourself in onError.
notFound runs with a real context (decorations and derives applied, params/query empty) and its return value goes through normal response coercion.
Two things hooks deliberately cannot do: onResponse cannot replace the response (that produced action-at-a-distance bugs in every framework that allowed it), and there is no onParse/onTransform/onBeforeHandle/onAfterHandle ladder β the previous version of Ma had all seven and nobody could say in which order they ran without reading the source.
mc builds an RPC client from nothing but the app's route map type. Navigation mirrors the path: static segments by property access, :params by bracket access, HTTP methods terminate the chain:
const client = mc<typeof app>("http://localhost:3000")
await client.users.get()
await client.users[":id"].get({ id: "1" })
await client.users.post({ body: { name: "Alice" } })
await client.search.get({ query: { q: "ma", limit: 3 }, headers: { "x-debug": "1" } })The argument object takes: one key per path param (flat, as strings), query (only if the route declares a query schema β typed as its input), body (only if declared β typed as its input, required), and optional headers. If a route has no params and no body, the argument is optional entirely.
Every call resolves β the client never throws on HTTP status. Network failures reject; HTTP-level failures are values:
const res = await client.users[":id"].get({ id: "1" })
res.ok // boolean β discriminant
res.status // number
res.headers // Headers
res.data // typed by ok/status as described in Typed errorsdata is parsed from the response by content type: application/json β parsed JSON, anything else β the body text. The body is always consumed β this client is for JSON APIs, not for streaming (grab app.fetch or plain fetch for that).
- Path params are
encodeURIComponent-escaped into the URL. queryvalues are stringified; arrays append the key once per element;undefinedvalues are skipped.bodyisJSON.stringify-ed withcontent-type: application/json.- Headers merge as: per-client defaults β per-call overrides (per-call wins on conflicts).
const client = mc<typeof app>(url, { headers: { authorization: `Bearer ${token}` } })
await client.me.get({ headers: { "x-request-id": "abc" } }) // both headers sentThe client is a Proxy that accumulates path segments. Symbol accesses return undefined (so console.log, iteration checks, and friends don't explode), and then is explicitly not a segment β otherwise await client.users would treat the proxy as a thenable and hang. The corollary: path segments named then or named after an HTTP method (get, post, ...) cannot be navigated. Name your routes like a person.
Pass the app itself to mc() and requests go straight into app.handle() β no server, no port, no network, no cleanup, and byte-for-byte the same code path a real request takes (routing, validation, hooks, cookies β everything):
const client = mc(app) // note: no <typeof app>, no URL β types are inferred from the argument
test("lists users", async () => {
const res = await client.users.get()
expect(res.ok).toBe(true)
expect(res.data).toEqual([{ id: "1", name: "Alice" }])
})This is the intended way to test Ma apps. It is also the honest reason mc has two modes: the network mode is the same client pointed at a URL instead of a handle function, so anything that passes against mc(app) behaves identically over the wire.
Reading β parsed lazily from the cookie header on first access, cached for the request. Quoted values are unquoted, percent-encoding is decoded:
.get("/session", (c) => {
const session = c.cookie("session") // string | undefined
return { session }
})Writing β c.setCookie() queues a set-cookie header that is appended to whatever response the request produces, including raw Responses you construct yourself and error responses:
c.setCookie("session", "abc", {
httpOnly: true,
secure: true,
maxAge: 3600, // seconds
path: "/",
sameSite: "strict", // "strict" | "lax" | "none"
// domain, expires also supported
})Multiple setCookie calls produce multiple set-cookie headers, as the spec requires. There is no signing or encryption β that's session-library territory, not framework territory.
One call, driven by the same schemas that validate requests and type the client. The schemas are the single source of truth; the spec cannot drift from the code:
const spec = app.openapi({
title: "My API",
version: "1.0.0",
description: "optional",
})
app.get("/openapi.json", () => spec)Per route: params and query schemas become parameters (path params always required: true, query params required per the schema), body becomes the requestBody, response the 200, and each errors entry its own status response. Paths convert from :param to {param} syntax.
Schema-to-JSON-Schema conversion tries, in order:
- A user-supplied
convertfunction β explicit beats implicit:app.openapi(info, { convert: (schema) => ({ ... }) }). - Standard JSON Schema β libraries implementing the
~standard.jsonSchemaextension (zod 4+) convert natively. Ma asks for the input side for params/query/body (what callers send) and the output side for responses and errors (what they receive) β so coercions and transforms document correctly from both directions. - Structural pass-through β a schema that already is a JSON Schema object (has
type,properties,anyOf, ...) is used as-is. This is how hand-rolled schemas participate: write them as JSON Schema with a~standard.validateattached and OpenAPI is free.
If none of those apply, Ma throws with the vendor name and what to do about it, rather than emitting a silently wrong spec. Vendor internals (~standard, $schema) are stripped from the output, and the result is plain JSON-serializable data.
Everything inferable is extractable β mostly useful at module boundaries where you want a named type without re-deriving it:
import type { InferContext, InferErrors, InferInput, InferOutput } from "@ma/core"
// The accumulated context state of an app β what c.get() can see at this point.
type Ctx = InferContext<typeof app> // { db: Database; user: User }
// Success data of a client method.
type User = InferOutput<(typeof client.users)[":id"]["get"]> // { id: string; name: string }
// Argument object of a client method β params, query, body, headers.
type CreateArgs = InferInput<typeof client.users.post> // { body: { name: string }; headers?: ... }
// Declared-error union of a client method.
type UserErrors = InferErrors<(typeof client.users)[":id"]["get"]>
// { ok: false; status: 404; data: { message: string }; headers: Headers } | ...Also exported for building on top of Ma: Client, ClientResponse, ClientResult, RouteMap, RouteDef, RouteSchemas, ExtractParams, MergeCtx, ContextState, SchemaInput, SchemaOutput, TypedResponse, ErrorResponse.
Ma is small on purpose. Some of these are cut features, some are honest costs of the design:
- No WebSocket, no SSE. Cut in the rewrite. Both are servable next to Ma with the platform's own APIs; neither earned its complexity inside the framework.
- No middleware chain. There is no
(c, next) => .... Derives, guards, and the four hooks cover the real use cases with clearer semantics; wrapapp.fetchfor anything truly global (compression, tracing). - Response schemas don't validate at runtime. They pin types and document. If your handler lies to the type checker with
as, the wire will carry the lie. - The client buffers. Every response body is consumed and parsed; there is no streaming client. Use
fetchdirectly for streams. - Closed error unions. Declared errors narrow exactly; undeclared runtime statuses exist outside the type (see Typed errors).
- Route segments named
get,post,put,delete,patch,head,options, orthencannot be navigated by the client proxy. - Guards are trusted.
.guard<TNarrow>narrows because you claim it; a wrong claim is a wrong type, same as a bad user-defined type guard. - Query strings are single-valued in
c.query(last wins). Multi-value needsc.url.searchParams. - No compression, no static file serving, no CORS helper, no rate limiter. Platforms and five-line wrappers do these fine.
src/
types.ts all type-level code: schema inference, route map, client tree
app.ts Ma β route registration, context accumulation, handle()
router.ts segment trie: static map + params + wildcard, with backtracking
context.ts Context β inputs, state, response helpers, cookies
client.ts mc() β proxy RPC client (network + in-process modes)
openapi.ts OpenAPI 3.1 from route registrations
error.ts HttpError
index.ts exports
The load-bearing ideas, so you can evaluate them:
- Phantom accumulation.
Ma<TCtx, TRoutes>carries two phantom fields,_ctxand_routes, that exist only at the type level. Every chained call returnsMawith a bigger type; the runtime object is the same mutable instance.mc<typeof app>andInferContext<typeof app>just read the phantoms. - Flat merges, never intersections. Route and context accumulation use mapped-type merges (
MergeRoute,MergeCtx) instead of&. Intersections nest one level per route and collapse tooling hovers intoA & B & C & ...soup β and TypeScript gives up on them entirely around a hundred routes deep. Flat maps stay flat. - One erased boundary. Handlers are stored internally as
(c: Context) => unknown. The cast from the precisely-typed public signature to that erased form happens in exactly one place (#add), which is the only place it can be audited. Everything past the boundary usesunknownand narrowing βanydoes not appear insrc/. - The type layer and the runtime layer don't overlap.
types.tshas no runtime exports; the runtime files have almost no generics. Inference bugs get fixed in one file; behavior bugs in another. - Snapshot scoping. Each route captures the guards and derives registered before it β the same rule the types follow. Type-level claims and runtime behavior agreeing by construction is most of what "end-to-end type safety" means here.
bun test # behavioral tests + compile-time type assertions, one suite
bun run check # tsgo across all three tsconfigs (see below)
bun run lint # oxlint β correctness and suspicious categories as errors
bun run fmt # oxfmt (bun run fmt:check in CI mood)Three tsconfigs, three jobs:
| Config | Checks | Purpose |
|---|---|---|
tsconfig.bun.json |
src/, examples/ |
the app-author experience, with Bun types |
tsconfig.deno.json |
src/ only |
no Bun types at all β proves the core is pure web standards |
tests/tsconfig.json |
src/, tests/ |
the suite, including the compile-time assertions |
tests/types.test.ts is the inference contract: a file of Expect<Equal<Actual, Expected>> assertions that only compiles if inference is exactly right β not assignable-right, Equal-right. Treat a red check there with the same seriousness as a failing runtime test; it is a failing test.
The strict base config is part of the design: exactOptionalPropertyTypes, noUncheckedIndexedAccess, verbatimModuleSyntax and friends are all on, and the source compiles clean under them.
Each is a single runnable file with instructions at the top:
| Example | Shows |
|---|---|
basic.ts |
CRUD, validation, derive, serving |
client.ts |
network RPC client, type extraction |
typed-errors.ts |
c.error, discriminated unions |
plugin.ts |
plugins, guards, groups |
openapi.ts |
OpenAPI 3.1 generation |
MIT