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.
import { Ma, mc } from "@ma/core"
import { serve } from "@ma/core/adapters/bun"
import { Type } from "@sinclair/typebox"
const app = new Ma()
.get("/hello/:name", {
params: Type.Object({ name: Type.String() }),
response: Type.Object({ message: Type.String() }),
}, (c) => ({
message: `Hello, ${c.params.name}!`,
}))
serve(app, { port: 3000 })- Design principles
- Routing
- Validation
- Context
- Response handling
- Typed errors
- Guards
- Plugins
- Typed client
- Test client
- WebSocket
- Server-sent events
- Lifecycle hooks
- Route groups
- Cookies
- Environment validation
- OpenAPI generation
- Adapters
- Type utilities
- Examples
- Correct type inference — including across
.then()chains, stored handlers, and plugin composition. No Hono #4765. - Speed — radix tree router, pre-compiled TypeBox validators, minimal allocations.
- Honest safety — no internal
anyin core.unknown+ narrowing instead. - Clean architecture — strict separation between the type layer (
src/types/) and runtime layer (src/core/).
All standard HTTP methods are supported. Each has two overloads: with and without a schema options object.
const app = new Ma()
// Simple handler — output type inferred from return value
.get("/ping", (c) => c.json({ pong: true }))
// With schema — validates input, pins output type
.post("/users", {
body: Type.Object({ name: Type.String() }),
response: Type.Object({ id: Type.Number(), name: Type.String() }),
}, (c) => ({
id: 1,
name: c.body.name,
}))Supported methods: get, post, put, delete, patch, head, options
Path parameters use :param syntax:
.get("/users/:id/posts/:postId", {
params: Type.Object({ id: Type.String(), postId: Type.String() }),
}, (c) => {
c.params.id // string
c.params.postId // string
})Wildcard routes use *:
.get("/files/*", (c) => { ... })Ma uses TypeBox as its validation library. Schemas are compiled once at startup via TypeCompiler.Compile() — zero per-request overhead.
.post("/users", {
params: Type.Object({ id: Type.String() }), // path params
body: Type.Object({ name: Type.String() }), // request body
query: Type.Object({ page: Type.Optional(Type.Number()) }), // query string
response: Type.Object({ id: Type.Number() }), // response shape (pins RPC output type)
}, handler)When validation fails, Ma returns a structured 400 response:
{
"error": "Validation failed",
"source": "body",
"issues": [{ "path": "/name", "message": "Expected string" }]
}Context carries derived state, parsed inputs, and the raw request. It flows through every handler and hook.
.derive() runs a function per request and merges the result into context:
const app = new Ma()
.derive((c) => ({
db: new Database(),
user: getUserFromHeader(c.header("authorization")),
}))
.get("/me", (c) => {
c.get("db") // Database
c.get("user") // User — fully typed
})Derives are layered — each one sees the previous:
.derive(() => ({ db: new Database() }))
.derive((c) => ({ user: c.get("db").findUser() })) // sees db.decorate() sets a value once at startup — zero per-request cost:
.decorate("version", "2.0.0")
.get("/info", (c) => c.json({ version: c.get("version") }))Inside a handler, typed inputs are available directly:
(c) => {
c.params // typed from params schema
c.body // typed from body schema
c.query // typed from query schema
c.req // raw Request
c.method // string
c.path // string
c.url // URL
c.header(name) // string | null
}Handlers can return any value — Ma coerces it to a Response:
| Return value | Response |
|---|---|
Response / TypedResponse<T> |
Pass through |
null / undefined |
204 No Content |
string |
text/plain |
number |
Status-only (e.g. return 404) |
Uint8Array / ArrayBuffer |
application/octet-stream |
| Everything else | application/json via JSON.stringify |
// All of these work:
.get("/text", () => "hello")
.get("/json", () => ({ id: 1 }))
.get("/empty", () => null)
.get("/status", () => 204)
.get("/explicit", (c) => c.json({ id: 1 }, { status: 201 }))The c.json(), c.text(), and c.html() methods brand the response for ExtractOutput inference and allow passing custom headers/status:
.get("/users", (c) => c.json(users, { status: 200, headers: { "x-total": "42" } }))Routes declare their error shapes. The client gets a discriminated union — no more hoping you remembered to check .ok:
.get("/users/:id", {
params: Type.Object({ id: Type.String() }),
response: Type.Object({ id: Type.Number(), name: Type.String() }),
errors: {
404: Type.Object({ message: Type.String() }),
403: Type.Object({ message: Type.String(), required: Type.String() }),
},
}, (c) => {
if (!found) throw new HTTPException(404, { json: { message: "Not found" } })
return user
})On the client, TypeScript narrows by ok and status:
const res = await client.users[":id"].get({ id: "1" })
if (res.ok) {
res.data // { id: number; name: string }
} else if (res.status === 404) {
res.data // { message: string }
} else if (res.status === 403) {
res.data // { message: string; required: string }
}.guard<TNarrow>() asserts a condition and narrows the context type for all downstream handlers. If the guard throws or returns a Response, the request short-circuits.
const app = new Ma()
.derive((c) => ({
user: getUserFromToken(c.header("authorization")), // User | null
}))
.guard<{ user: User }>((c) => {
if (!c.get("user")) throw new HTTPException(401)
})
.get("/me", (c) => {
c.get("user").name // User — not User | null. No cast, no `!`
})Guards only apply to routes registered after them. Routes before the guard are unaffected:
.get("/public", handler) // no guard
.guard<{ user: User }>(check)
.get("/private", handler) // guardedGuards also inherit into .group() calls:
.guard<{ user: User }>(check)
.group("/admin", (g) => g
.guard<{ user: Admin }>(adminCheck)
.get("/stats", handler) // both guards apply
).use() applies a function that transforms the app. Plugins are plain functions — no special wrapper:
function auth<T extends Record<string, unknown>, R extends Record<string, unknown>>(app: Ma<T, R>) {
return app
.derive((c) => ({ user: getUser(c) }))
.guard<{ user: User }>((c) => {
if (!c.get("user")) throw new HTTPException(401)
})
}
const app = new Ma()
.use(auth)
.get("/me", (c) => c.json(c.get("user"))) // user is UserPlugins compose naturally:
.use(timing)
.use(auth)
.use(adminOnly)
.get("/dashboard", handler)mc constructs a fully typed RPC client from the app's route map:
const client = mc<typeof app>("http://localhost:3000")
// Navigate by path segments
const res = await client.users.get()
const user = await client.users[":id"].get({ id: "1" })
// POST with body
await client.users.post({ body: { name: "Alice" } })
// Query params
await client.users.get({ query: { page: 2 } })Pass default headers (e.g. auth tokens) or per-request headers:
// Default headers for every request
const client = mc<typeof app>("http://localhost:3000", {
headers: { authorization: "Bearer token" },
})
// Per-request override
await client.users.get({ headers: { "x-request-id": "abc" } })import type { InferOutput, InferInput, InferErrors } from "@ma/core"
type User = InferOutput<typeof client.users[":id"]["get"]>
// → { id: number; name: string; email: string }
type CreateArgs = InferInput<typeof client.users["post"]>
// → { body: { name: string; email: string } }
type Errors = InferErrors<typeof client.users[":id"]["get"]>
// → { ok: false; status: 404; data: { message: string } }Pass the app instance directly to mc() — no server needed, types inferred automatically:
const client = mc(app) // no <typeof app>, no URL, no server
const res = await client.users.get()
expect(res.data).toEqual([...])This calls app.handle() directly. Same typed API, zero network overhead, no port conflicts, no cleanup.
// With headers
const authed = mc(app, { headers: { authorization: "Bearer token" } })
const me = await authed.me.get()Bidirectional typed WebSocket with receive (client→server) and send (server→client) schemas:
app.ws("/chat", {
receive: Type.Object({
type: Type.Literal("message"),
text: Type.String(),
}),
send: Type.Object({
type: Type.Literal("broadcast"),
text: Type.String(),
from: Type.String(),
}),
open(ws) {
ws.send({ type: "broadcast", text: "Connected!", from: "system" })
// ws.send() auto-stringifies — no JSON.stringify needed
},
message(ws, data, ctx) {
// data.type is "message", data.text is string — typed from receive schema
ws.send({ type: "broadcast", text: data.text, from: "user" })
},
close(ws, code, reason, ctx) { ... },
})receiveschema validates incoming messages at runtime. Invalid messages close with code 1008.sendschema typesws.send()— objects are auto-stringified.- Context (from
.derive(),.decorate()) is available in all handlers. - Path params work:
.ws("/rooms/:id", { ... })
.get("/events", (c) => c.sse(async (emit) => {
emit({ count: 1 })
emit({ count: 2 }, { event: "update", id: "2" })
}))
// With typed schema
.get("/typed-events", (c) => c.sse(
Type.Object({ count: Type.Number() }),
async (emit) => {
emit({ count: 1 }) // typed from schema
},
))Hooks execute in a fixed order. Any hook can short-circuit by returning a Response.
onRequest → onParse → onTransform → onBeforeHandle → [handler]
→ onAfterHandle → onError → onResponse
app
.onRequest((c) => {
// Runs first — good for auth, rate limiting
// Return a Response to short-circuit
})
.onBeforeHandle((c) => {
// Last chance before handler
})
.onAfterHandle((c, response) => {
// Can replace the response
return new Response("replaced")
})
.onError((c, error) => {
// Catches any thrown error
return new Response("Error", { status: 500 })
})
.onResponse((c, response) => {
// Post-response — logging. Cannot mutate.
console.log(c.method, c.path, response.status)
})Per-route hooks via the options object:
.get("/special", {
hooks: {
onBeforeHandle: [(c) => { ... }],
},
}, handler)Prefix routes and share context with .group():
app.group("/api/v1", (g) => g
.get("/users", handler) // → GET /api/v1/users
.get("/posts", handler) // → GET /api/v1/posts
)Groups inherit parent guards:
app
.guard<{ user: User }>(authCheck)
.group("/admin", (g) => g
.get("/stats", handler) // guard applies
).get("/me", (c) => {
const session = c.cookie("session") // string | undefined
c.setCookie("session", "abc123", {
httpOnly: true,
secure: true,
maxAge: 3600,
sameSite: "strict",
path: "/",
})
return c.json({ session })
})Validate process.env at startup with a TypeBox schema. Fails fast with clear error messages:
const app = new Ma()
.env(Type.Object({
DATABASE_URL: Type.String(),
PORT: Type.String(),
DEBUG: Type.Optional(Type.String()),
}))
.get("/config", (c) => {
c.get("env").DATABASE_URL // string — typed and validated
})Generate an OpenAPI 3.1 spec from your route schemas — TypeBox schemas are the single source of truth:
const spec = app.openapi({
title: "My API",
version: "1.0.0",
description: "Auto-generated from TypeBox schemas",
})
// Serve it
app.get("/openapi.json", () => Response.json(spec))The spec includes path params, query params, request body, response schemas, and typed error responses — all derived from the same schemas that power your validation and client types.
import { serve } from "@ma/core/adapters/bun"
const server = serve(app, { port: 3000, hostname: "0.0.0.0" })
// server.port, server.stop()Handles both HTTP and WebSocket upgrades.
import { serve } from "@ma/core/adapters/deno"
serve(app, { port: 3000 })app.notFound((c) => c.json({ error: "Not found", path: c.path }, { status: 404 }))import type {
InferContext, // Extract context type: InferContext<typeof app>
InferOutput, // Response body type from a client method
InferInput, // Request args type from a client method
InferErrors, // Error union from a client method
Client, // Full client type from a route map
ClientResponse, // Response wrapper: { ok, status, data, headers }
ClientResult, // Discriminated union for typed errors
RouteMap, // Map of "METHOD /path" → RouteDefinition
MergeCtx, // Flat merge of context types
ExtractParams, // "/users/:id" → { id: string }
ExtractOutput, // Handler return type extraction
} from "@ma/core"| Example | What it demonstrates |
|---|---|
basic.ts |
CRUD, schemas, derives, auto-response |
typed-context.ts |
Layered derives, stored handlers |
client.ts |
Typed RPC client, InferOutput/InferInput |
client-headers.ts |
Default and per-request headers |
test-client.ts |
mc(app) — no server needed |
typed-errors.ts |
Discriminated union error responses |
guard.ts |
Type-narrowing guards, scoping, groups |
plugin.ts |
.use() composition |
typed-ws.ts |
Bidirectional typed WebSocket |
openapi.ts |
OpenAPI generation from schemas |
src/
types/ ← All generic complexity lives here
response.ts TypedResponse brand, ExtractOutput
route.ts RouteMap, MergeRoute, path extraction
context.ts Context generics, MergeCtx
schema.ts TypeBox integration
client.ts RPC client type construction
utils.ts Equal, Expect, Simplify
core/ ← Pure runtime, minimal generics
router.ts Radix tree router
context.ts Context class
lifecycle.ts Hook pipeline
app.ts Ma class — glue layer
http-exception.ts HTTPException
cookies.ts Cookie parsing/serialization
adapters/
bun.ts Bun.serve() adapter
deno.ts Deno.serve() adapter
client/
index.ts mc() RPC client runtime
Hard rules for core/:
- No
any. Useunknown+ narrowing. - Types come from
types/, not defined inline. - Each file has one clear responsibility.
MIT