Skip to content

Quick start

This page builds a small users API from an empty folder: three routes, a validated body, an error of your own and a test. It needs only Bun.

Terminal window
mkdir users-api && cd users-api
bun init -y
bun add @tetsujs/core zod

bun init already sets up TypeScript the way Tetsu needs. To add Tetsu to an existing project, see Installation.

A controller is a name and a function from its dependencies to its routes. Here a Map stands in for a database:

src/users.ts
import { controller, httpError, route } from "@tetsujs/core";
import { z } from "zod";
export interface User {
id: number
id
: number;
name: string
name
: string;
email: string
email
: string;
}
const NewUser = z.object({ name: z.string().min(1), email: z.email() });
const UserId = z.object({ id: z.coerce.number().int().positive() });
export const usersController = controller("Users", (
users: Map<number, User>
users
: Map<number, User>) => ({
list: 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"

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",
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: {};
}) => User[]

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
: () => [...
users: Map<number, User>
users
.values()],
}),
get: 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: UserId },
handler: (ctx: {
readonly out: Outgoing;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}) => User

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;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
}
ctx
) => {
const
const user: User | undefined
user
=
users: Map<number, User>
users
.get(
ctx: {
readonly out: Outgoing;
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
);
if (!
const user: User | undefined
user
) throw httpError(404, "USER_NOT_FOUND", "No such user");
return
const user: User
user
;
},
}),
create: 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: "/users"

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",
schema: { body: NewUser },
handler: (
ctx: {
readonly params: {};
readonly out: Outgoing;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
name: string;
email: string;
};
}
ctx
) => {
const
const user: {
name: string;
email: string;
id: number;
}
user
= {
id: number
id
:
users: Map<number, User>
users
.
size: number

@returns ― the number of elements in the Map.

size
+ 1, ...
ctx: {
readonly params: {};
readonly out: Outgoing;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
name: string;
email: string;
};
}
ctx
.
body: {
name: string;
email: string;
}
body
};
users: Map<number, User>
users
.set(
const user: {
name: string;
email: string;
id: number;
}
user
.
id: number
id
,
const user: {
name: string;
email: string;
id: number;
}
user
);
ctx: {
readonly params: {};
readonly out: Outgoing;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
name: string;
email: string;
};
}
ctx
.
out: Outgoing

Response parameters for the serialized handler result.

out
.
status: number | undefined
status
= 201;
return
const user: {
name: string;
email: string;
id: number;
}
user
;
},
}),
}));
  • ctx.params.id is a number: the schema converts the :id segment before the handler runs, and /users/abc never reaches it.
  • ctx.body exists only on create, the route with a body schema, and has the schema’s type.
  • httpError(404, "USER_NOT_FOUND", …) is your own error, in the same shape as the framework’s.

createApp turns the routes into plain data that Bun.serve takes as it is:

index.ts
import { createApp } from "@tetsujs/core";
import type { User } from "./src/users";
import { usersController } from "./src/users";
const
const users: Map<number, User>
users
= new Map<number, User>();
const app = createApp({ routes: usersController(
const users: Map<number, User>
users
) });
Bun.serve({ ...app,
port?: string | number | undefined

The port the server listens on

@default ― process.env.PORT || "3000"

port
: 3000 });
console.log("Listening on http://localhost:3000");
Terminal window
bun --watch index.ts
Terminal window
curl -X POST localhost:3000/users -d '{"name":"Ada","email":"ada@example.com"}'
{ "id": 1, "name": "Ada", "email": "ada@example.com" }

The body is parsed as JSON, the route’s default, whatever content-type the client sends (curl’s -d sends a form type). A request the schema refuses gets a 422 with every issue at once:

Terminal window
curl -X POST localhost:3000/users -d '{"name":"","email":"nope"}'
{
"status": 422,
"message": "Validation failed",
"error": "VALIDATION_FAILED",
"issues": [
{ "message": "Too small: expected string to have >=1 characters", "path": ["body", "name"] },
{ "message": "Invalid email address", "path": ["body", "email"] }
]
}

Your own error has the same shape:

Terminal window
curl localhost:3000/users/7
{ "status": 404, "message": "No such user", "error": "USER_NOT_FOUND" }

Routing is Bun’s, and only a real socket reaches it. serve() from @tetsujs/core/testing starts the application on a free port and stops it when the test file finishes:

src/users.test.ts
import { createApp } from "@tetsujs/core";
import { serve } from "@tetsujs/core/testing";
import { expect, test } from "bun:test";
import { usersController } from "./users";
const
const request: RequestFn
request
= serve(createApp({ routes: usersController(new Map()) }));
test("creates a user and reads it back", async () => {
const
const created: Response
created
= await
const request: RequestFn
(path: string, init?: RequestInit) => Promise<Response>
request
("/users", {
method?: string | undefined

A string to set request's method.

method
: "POST",
body?: BodyInit | null | undefined

A BodyInit object or null to set request's body.

body
: JSON.stringify({
name: string
name
: "Ada",
email: string
email
: "ada@example.com" }),
});
expect(
const created: Response
created
.
status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
).toBe(201);
expect(await (await
const request: RequestFn
(path: string, init?: RequestInit) => Promise<Response>
request
("/users/1")).json()).toEqual({
id: number
id
: 1,
name: string
name
: "Ada",
email: string
email
: "ada@example.com",
});
});
Terminal window
bun test