Hono’s type system is one of its greatest strengths. If you don’t take some time to understand the basics though, it can prove to be a frustrating barrier to entry. As a newer framework, and one dedicated to flexibility, Hono’s official documentation mainly covers core concepts and base-cases. Dozens of official and community middleware and templates will steer you towards scalable and maintainable solutions, but the patterns and details are largely left to you.
After working for years with meta-frameworks that seem to have an opinion about everything, this feels like a breath of fresh air. Hono’s middleware and helpers can be used out-of-box to meet many projects’ basic requirements, but it’s also remarkably simple to extend or replicate them to meet your project’s specific needs.
Learning Hono hands-on
Hono’s approach to request validation is an ideal case study. Its core hono/validator
implementation is only ~150 lines, half of which are imports and types. The logic itself is really straightforward, and can be trivially extended or modified locally. In fact, Hono’s validator-specific middleware are all built on validator
, and its types.
If you want to get the most out of this article—and Hono—you’ll need to be comfortable with TypeScript and generics. You should be familiar with web API and TypeScript basics, but it’s ok if you’re a beginner, or prefer to use TypeScript sparingly: Hono’s types and utilities will do most of the heavy lifting for us.
To get a clear picture of how Hono middleware works, we’ll implement the same validation middleware using three approaches, each peeling back a layer of abstraction:
- First with
@hono/zod-validator
—the out-of-box solution, - Then with
hono/validator
—if you want to use your own validator, or bake in error processing, - And finally with Hono’s
createMiddleware
—not recommended for production, but a great way to take a closer look at how Hono works.
Hono’s validator
is especially powerful in combination with its RPC client, so we’ll also take a quick look at how route typing plugs into hono/client
. We won’t be covering OpenAPI integration—that deserves its own discussion—but most topics we address will be relevant in any middleware or handler.
Low-lift validation with @hono/zod-validator
If you’re already using a type-safe schema library, like Zod or TypeBox, and you just want to plug your schema in and go, Hono’s got you covered. You just install the relevant package-specific validation middleware, and it handles most of the boilerplate for you.
I’m a long-time Zod fan, so we’ll start with Hono’s zod-validator
, but none of the examples or discussion will delve too deeply into Zod specifics. Instead, we’ll be focusing on what we can learn about Hono’s middleware typing from the package’s internals.
Sharing valid data with Context
The first piece of Hono typing we really need to understand is the Context
object. Whether we’re using one of the dozens of official and community middleware—or creating our own—we’ll be working with Context
. It exposes the app environment—including any bindings for Cloudflare environments, the Request
and Response
, and a variety of helpers for reading and writing data. You can read more about those in the Hono Context
API docs.
export type Context<
// We'll get to these type parameters in a moment
E extends Env = any,
P extends string = any,
I extends Input = {}
> = {
// Cloudflare bindings and env variables
env: E["Bindings"];
// Augmented Request with optionally-validated data
get req(): HonoRequest<P, I["out"]>;
get res(): Response;
// ...
}
Crucially, Context
allows us to share data between middleware and handlers type-safely. This can be useful in a number of ways, but we’ll start with the c.req.valid
method, which allows us to access any request data validated by validator
(or middleware like zod-validator
that use it internally).
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
// I like to centralize these in a directory like /dtos
// or /schemas, but it really depends on your use-case
const ZSearchQuery = z.object({
search: z.string(),
});
const app = new Hono()
.get(
'/posts',
// Must be handler-specific for type-safety
zValidator('query', ZSearchQuery),
// After going through middleware, Context
// is passed into the handler
async (c) => {
const { search } = c.req.valid('query');
// We know `search` is a string in this scope, so we
// can handle the request type-safely from here on
}
)
When we pass zValidator
a target ('query'
) and a schema, the schema’s output becomes type-safely available in the handler (or subsequent middleware). Hono supports six validation targets, representing the most common formats for request data.
json
-
form
(multipart/form-data
orapplication/x-www-form-urlencoded
) query
param
header
cookie
For simplicity, here we only validate the query string, but you can validate as many targets as you’d like by chaining multiple validators.
For validated types to be inferred correctly, validation middleware must be added in the handler, like in the example above. Chaining your validator with
app.use
will result in the following TS error:
“Argument of typestring
is not assignable to parameter of typenever
.”
Customizing the error hook
The zod-validator
package makes it really easy to enforce type-safety within handlers, but what happens when requests fail validation? By default, zValidator
immediately ends the request, returning a 400
with a body containing the full ZodError
object.
While convenient in development, it’s not great for production. This is especially true if you have internals that need obscuring (like auth flows), if you want to standardize error responses, or if your app has complex error-handling requirements (like logging or alerts).
Some OAuth flows use validation logic that takes the unparsed body as input (typically in combination with a signature in the headers). In these cases, I’ve found its simplest to verify the request before moving on to validation.
To override this default behavior, zValidator
accepts a third hook
argument: a callback that exposes the validation result and our good friend Context
. If validation fails, you can send a custom response, or throw to an error handler for additional processing.
While it’s great to have this flexibility, responding to errors consistently makes APIs easier to build, troubleshoot, and work with. By abstracting the hook, we can reuse it whenever we validate, ensuring that invalid requests are handled the same way each time.
This gets tedious quickly though, and can be difficult to maintain. To save ourselves the trouble of injecting our error hook each time we call zValidator
, we can instead bake it into a custom middleware.
import type { ValidationTargets } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
// Your custom error formatter
import { formatZodError } from '@/lib/zod-error';
export const customZodValidator = <
// json, form, query, param, header, cookie
Target extends keyof ValidationTargets,
Schema extends z.ZodSchema
>(target: Target, schema: Schema) => {
return zValidator(target, schema, (result, c) => {
// Early-return (or throw) on error
if (!result.success) {
// Error requirements will vary by use-case
return c.json({
timestamp: Date.now(),
message: `invalid ${target}`,
issues: formatZodError(result.error.issues),
}, 400);
}
// Otherwise return the validated data
return result.data;
});
};
As you can see, the implementation is simple enough: zValidator
does the heavy lifting both at compile- and at run-time. We only need a few generics to make zValidator
aware of the argument types passed to our wrapper—essentially we’re prop-drilling the type—and it will take care of communicating with Hono’s type system.
If you’re not familiar with generics, I strongly recommend reading through the TypeScript Generics docs, or seeking out resources that better fit your learning style. TLDR? They make types dynamic, helping us represent functions like
zValidator
that return different results depending on argument subtypes.
To allow for one-off routes with distinct error-handling requirements, we could also add an optional override hook. The typing for that is a little more complicated though, so we won’t get into that just yet.
More flexibility with hono/validator
First, let’s get a better understanding of how schema output types get from zValidator
to our handlers. Behind the curtain, it’s just a Zod-specific wrapper around validator
, and Hono offers equivalents for Typebox, Typia, and Valibot.
If you don’t see your favorite validator (or parser) on the list, or want to get creative with your error processing, fret not. It’s fairly trivial to reproduce the essentials yourself. Hono tools are built to be extended, and the source code is refreshingly accessible.
// import { zValidator } from '@hono/zod-validator';
import { validator } from 'hono/validator';
export const customZodValidator = <
Target extends keyof ValidationTargets,
Schema extends z.ZodSchema
>(target: Target, schema: Schema) => {
// return zValidator(target, schema, (result, c) => {
return validator(target, async (value): Promise<z.output<Schema>> => {
// We have to run validation ourselves
const result = await schema.safeParseAsync(value);
if (!result.success) {
return c.json({
timestamp: Date.now(),
message: `invalid ${target}`,
issues: formatZodError(result.error.issues),
}, 400);
}
return result.data;
});
};
Changing only three lines, we can update our example to remove the @hono/zod-validator
dependency, and decouple our logic from Zod. At this level of abstraction, you’re free to validate request data any way you’d like. You can then retrieve valid data in the handler, using c.req.valid
.
How does this actually work though?
When we use validator
, the callback’s (non-Response
) return type gets added to a type map, using the path, method, and target as keys. To achieve this, validator
leverages Hono’s MiddlewareHandler
type. Like Context
, MiddlewareHandler
takes three generic arguments, for Env
, path, and Input
:
type MiddlewareHandler<
E extends Env = any,
P extends string = string,
I extends Input = {}
> = (c: Context<E, P, I>, next: Next) => Promise<Response | void>;
type Context<
E extends Env = any,
P extends string = any, // path
I extends Input = {}
> = {
/** */
}
type Env = {
Bindings?: Bindings; // object
Variables?: Variables; // object
};
type Input = {
in?: {};
out?: {};
outputFormat?: ResponseFormat; // 'json' | 'text' | 'redirect' | string
};
Env
and Input
are the type parameters you’ll manually work with the most. Env
exposes any environment variables (Bindings
), along with any values you’ve set
in Context
in your middleware (Variables
), while Input
represents any request data validated using hono/validator
.
This would be the Input
type for our simple search query, for example:
{
// Used by Hono internals, always same union
// If you know what they do, let me know in the comments!
in: {
query: {
search: ParsedFormValue | ParsedFormValue[];
}
// json: {};
// form: {};
// param: {};
// header: {};
// cookie: {};
};
// Types you'll work with
out: {
query: {
search: string;
}
};
}
Downstream handlers use the out
type to determine which targets have been validated, and to appropriately type values returned from c.req.valid
. This is essentially Hono’s secret sauce: it uses generics to merge types into a format useable across the request lifecycle.
Using a different validator
All we really need then, is a target and an output type. We can easily update our custom Zod validator to accept a generic parse function (or one from a different library), as long as we make sure validator
generically knows the return type.
// Any validation function you provide must
// take unknown data and return data of a known type,
// or produce some kind of error
type ValidationFunction<T, E extends Error = Error> = (data: unknown) => (
{ success: true; data: T; }
| { success: false; error: E }
);
export const customAgnosticValidator = <
Target extends keyof ValidationTargets,
T extends Record<string, any>
>(target: Target, validate: ValidationFunction<T>) => {
return validator(target, (value, c) => {
const result = validate(value);
if (!result.success) {
return c.json({
timestamp: Date.now(),
message: `invalid ${target}`,
issues: formatError(result.error.issues),
}, 400);
}
return result.data;
});
};
This approach is ideal if you want to minimize your dependencies, or if you want to use an unsupported validator. Otherwise, the sturdiest (and most cost-effective) solution is to build on top of an existing package-specific Hono validator.
Getting extra with createMiddleware
To get an even closer look, let’s take things a step too far, and implement our own version of validator
. While you wouldn’t want to do this for your validation layer, it will give us a chance to manually get and set values in Context
, which is really a game-changer for things like auth.
To keep typing simple, we’ll take advantage of Hono’s createMiddleware
helper. This factory method ensures that your middleware typing can be read by subsequent handlers.
Under the hood, createMiddleware is essentially a type utility. It doesn’t do any logic, but it does hook your middleware into Hono’s type system, which plays a key role in communicating types between middleware, handlers, and the Hono client. It does not allow you to automatically share types between middleware.
Instead of using the Input
type though, we’ll use the Env
type. There’s no way to set the data that’s available on c.req.valid
without using validator
(or forking Hono), but Context
comes with a getter and setter that we can use to type-safely share our own custom data.
import { createMiddleware } from 'hono/factory';
const overEngineeredAgnosticValidator = <
Target extends keyof ValidationTargets,
T extends Record<string, unknown>
>(target: Target, validate: ValidationFunction<T>) => {
return createMiddleware<{
Variables: {
validated: Record<Target, T>
}
}>(async (c, next) => {
// Get and format target data from Request
// https://github.com/honojs/hono/blob/b2affb84f18746b487a2e02f0b1cd18e2bd8e5f5/src/validator/validator.ts#L72
const value = await getTargetData(c, target);
const result = validate(value);
if (!result.success) {
return c.json({
timestamp: Date.now(),
message: 'Invalid Payload',
issues: formatError(result.error),
}, 400);
}
const validated = {
// Get previously-validated data
// `c.get('validated')` would also work
...c.var.validated,
[target]: result.data,
};
// Update the validated data in context
c.set('validated', validated);
// Don't forget to await. It's not necessary
// until it is, and then it's a pain to retrofit
await next();
});
};
Since we’re not using validator
, we won’t be able to access our data in the handler using c.req.valid
. Instead, we’ll use the createMiddleware
type generic to specify that we’ll be setting a validated
property in Context
variables, whose value is our parse result. We could then access our results in the handler like this:
const app = new Hono()
.get(
'/posts',
customValidator('query', ZSearchQuery.parse),
// Context allows us to grab validated request data
async (c: Context) => {
// `c.get('validated').query` would also work
const { search } = c.var.validated.query;
// We know `search` is a string in this scope
}
);
Querying validated endpoints with Hono RPC
If your app has a front-end, Hono’s RPC client is a popular choice for keeping your types synced across your stack. It brings intellisense and type-safety to your request construction, representing resources as objects nested by path and method.
import { hc } from 'hono/client';
// type AppType = typeof app;
import type { AppType } from '@/server';
// Client uses type map to infer available endpoints
const client = hc<AppType>('BASE_URL');
export const getPosts = async (search: string) => {
// We can dot-index into the endpoint and method we want
// and any `json` or `text` return types are inferred
return await client.posts.$get({
// The client will let us know what data is required
query: { search },
});
}
There are a few gotchas to usage though, notably that the RPC client only works with json
and text
responses. If your endpoint doesn’t return either, you can still use the client, but without the benefit of any additional type-safety. Moreover, if an endpoint returns both json
and an incompatible method (e.g, c.req.body
), none of the responses will be inferred.
Note that unlike a tool like
trpc
, Hono’s RPC client isn’t linked to code instances, so shortcuts likecmd+click
in your code editor won’t take you to the handler.
I haven’t worked with it extensively, so we’ll need to save a more in-depth discussion for another time, but it’s worth getting a sense of how the types inferred from our backend code get used by the client (and how they don’t).
Inferred request and response types
As the client’s behavior suggests, all the (chained) middleware and handler types for an app or route are merged into a type map that’s keyed by endpoint and method, and includes the inferred input and union of output types.
{
"/posts": {
$get: {
// Success response
// Request data validated with `hono/validator`
input: {
query: {
search: string;
};
};
// Data returned from handler
output: { data: Data[]; };
outputFormat: "json";
status: 200;
} | {
// Error response
input: {
query: {
search: string;
};
};
output: { message: string; };
outputFormat: "json";
status: 400;
}
};
}
In this case, we see that our posts endpoint requires a search
query value, and returns either a 200
with some data, or a 400
with an error message. Remember that only text
or json
responses returned from the handler will be included. Responses returned from middleware or helpers like notFound
or onError
are not included either. This behavior is not supported by Hono’s current type system, but it’s a known issue that may be addressed in the future.
To get around this, you can explicitly set handler types yourself, though that’s not especially ergonomic, and is somewhat counterintuitive. The best solution will depend on your use-case, but using a standardized error response format combined with some custom type checking should easily bridge the gap for now.
Regardless, it will often also be necessary to additionally parse data client-side: complex objects like Date
are serialized for HTTP requests, and clients generally don’t deserialize them for you. While this might seem cumbersome, it’s fairly trivial to add a validation layer around Hono’s client (or any TypeScript HTTP client) that transforms values as-needed. That, though, is a tomorrow problem.
It’s middleware all the way down
For now, I hope that I’ve left you feeling excited to take your middleware to the next level, and confident to start exploring the Hono source code if you haven’t already! It’s an amazing feat of engineering, and a great resource throughout the development process.
Hono’s flexibility—which extends from its cross-runtime compatibility to its helper methods—opens the door to a highly composable and type-safe architecture. It’s simple to build on, introducing additional complexity and abstraction only as needed.
This approach radically simplifies workflows—like auth and rate limiting—that often require data to be shared between multiple middleware layers before the request even hits the handler.
I’m currently having a lot of fun building auth into a Hono app using the new Lucia Auth guide, which is an awesome resource if you want to roll your own auth, or just learn more. I’m still ironing out some kinks, but I look forward to publishing an article on Hono auth in the coming months!
Until then, if you need help with Hono or are seeking inspiration for your next project, check out the Hono Discord. I’ve found it to be an incredibly welcoming and supportive community, following the example set by the project authors, maintainers, and contributors.
Top comments (0)