reformating my elysia task api using hono
migrating from elysia to hono. this project is to help familiarize myself with hono syntaxes. as well as to reiterate on the previous elysia-challenge project.
don't get me wrong, i'm comfortable and love using elysia, but i don't want to overcommit to the framework considering that its pretty niche after all
i'll still be using bun, as its perfect for me.
check the api here: task-api
currently working on wiring up
@hono/zod-openapi+@hono/swagger-uiit's not as plug-and-play as elysia's built-in swagger. in the meantime, check the .http file for raw request references or see the docs below
status accepts: "pending" · "in-progress" · "completed"
| method | what it does | body |
|---|---|---|
GET /tasks/all |
return all tasks | — |
POST /tasks |
create a task | title: string, status?: enum |
GET /tasks/:id |
return task by id |
— |
PATCH /tasks/:id |
update task | title?: string, status?: enum |
DELETE /tasks/:id |
delete task | — |
-
unlike elysia which is a server instance, hono itself implements the Web Fetch API interface
-
hono requires you to expose the handler through
fetchas for elysia, its all handled by.listen() -
Bun.serve({ port: Number(Bun.env.PORT ?? 3000), hostname: "0.0.0.0", fetch: app.fetch, });
-
everything in hono is explicit by design, for example:
- you need to specify the type of response from the server, whereas elysia handles it for you unless explicitly stated:
// with hono app.get("/", (c) => { return c.text("Hello Hono!"); // needs an explicit response object }); // with elysia .get("/", () => { "Hello, Elysia!"; // will auto-converts return values })
- hono by design is agnostic and minimal framework, you need to opt into validation (e.g. zod, valibot, etc.) to get the same built-in parsing + typing + schema integration that elysia has.
-
hooks works a bit differently in hono. everything lives under
(c)as a (single context object). elysia give you a destructured context-
// with hono app.post('/user', tbValidator('json', Body), (c) => { const { name } = c.req.valid('json') return c.json({ name }) }) // with elysia .get('/all', async ({ set }) => { set.status = 200; return await getTasks(); })
-
-
hono exposes a fetch-compatible handler, so you can call it directly in tests
-
// bun test runner with hono: const res = await app.request("/"); // you only need to specify the path without manually specifying the baseUrl const hello = await res.text(); expect(hello).toBe("Hello Hono!"); // bun test runner with elysia: const res = await app.handle(new Request(`${BASE_URL}`)); // no more explicit BASE_URL const at your test runner const hello = await res.text(); expect(hello).toBe("Hello Hono!");
-
-
so far, the
functionandroutingbehave the same way in hono. -
you could use
safeParsefor your incoming request, be itPOST, or evenDELETE. using it is simple too, you can do this: -
app.get("/:id", zValidator("param", TaskIdSchema), (c) => { const { id } = c.req.valid("param"); TaskIdSchema.safeParse(id); return c.json(service.getById(id), 200); });
- zod will validate incoming req params.
-
i haven't bother putting zod validation error in the test runner but i plan on doing so later.
-
with zod, you can assert specific schema field, if you for some reason need a type-checking for one of your schema.
-
for example, instead of creating a separate schema, you should do this instead:
-
export const FullTaskSchema = z.object({ id: z.string().min(1), title: z.string().min(4), status: z.enum(["completed", "pending", "in-progress"]).optional(), createdAt: z.string().min(1), }); export type Task = z.Infer<typeof FullTaskSchema>; export type Status = z.Infer<typeof FullTaskSchema>["status"];
-
and it'll work the same way as if you've created a separate schema for said field.
-
btw,
z.Inferis the new canonical form for Zod v4. easy to miss since the old v3 used a lowercasez.infer. -
i tried using refreshable
appinstance for my test runner but the problem is that youronErrordoesn't come along with it. -
be wary that creating a new
Hono()instance and mountingTaskRoutes(service)on it, would completely bypass yourapp.ts. -
your instance would have no
onErrorbecause you never gave it one. its a new and separate hono instance after all. -
// test creates a bare Hono instance — onError not inherited beforeEach(() => { service = new TaskService(); testApp = new Hono(); testApp.route("/tasks", TaskRoutes(service)); // still no onError });
-
so you must force feed the onError at your
beforeEach(). -
beforeEach(() => { service = new TaskService(); testApp = new Hono(); testApp.route("/tasks", TaskRoutes(service)); testApp.onError((err, c) => { if (err instanceof TaskNotFound) { return c.json( { error: err instanceof Error ? err.message : "Unknown Error", timestamp: new Date().toISOString(), }, 404, ); } return c.json( { error: err instanceof Error ? err.message : "Unknown Error", timestamp: new Date().toISOString(), }, 500, ); }); });
-
when you isolate the router in tests with a fresh Hono instance for dependency injection, you also lose the global error handler
-
even though, hono automatically inject your global
onErrorto your subroutes just fine. -
this is a specific problem and i'd likely be fixing it later.
bun + hono + an in-array memory
made with ◉‿◉