@tetsujs/typebox turns a TypeBox
schema into a DTO you can use in any part of a route’s schema. Validation
is compiled, types are inferred, and the same schema goes into the OpenAPI
document. See Validation for how schemas are
used in a route.
Terminal window
bunaddtypebox@tetsujs/typebox
typebox is a peer dependency, version 1.3.34 or later, so the application
picks the version and has one copy of it.
: 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: "PUT",
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?: {
readonly params: TypeBoxSchema<Type.TObject<{
id:Type.TInteger;
}>>;
readonly body: TypeBoxSchema<Type.TObject<{
name:Type.TString;
email:Type.TString;
}>>;
} |undefined
Validation schemas; an absent part is neither parsed nor typed.
schema: {
params: TypeBoxSchema<Type.TObject<{
id:Type.TInteger;
}>>
params:
constUserParams:TypeBoxSchema<Type.TObject<{
id:Type.TInteger;
}>>
UserParams,
body: TypeBoxSchema<Type.TObject<{
name:Type.TString;
email:Type.TString;
}>>
body:
constCreateUser:TypeBoxSchema<Type.TObject<{
name:Type.TString;
email:Type.TString;
}>>
CreateUser },
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: {
id: number;
};
readonly body: {
name: string;
email: string;
};
}
ctx) =>
constusers: {
update(id:number, user: {
name:string;
email:string;
}): {
id:number;
};
}
users.update(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
readonly body: {
name: string;
email: string;
};
}
ctx.
params: {
id: number;
}
params.id,
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
readonly body: {
name: string;
email: string;
};
}
ctx.
body: {
name: string;
email: string;
}
body),
id: number
}),
}));
tb() compiles the schema once, and every request runs the compiled check.
Type is TypeBox’s own, re-exported, so TypeBox’s documentation applies.
The types Static, StaticDecode and TSchema are re-exported too. What
tb() returns is still a plain JSON Schema object: it serializes cleanly
and nests into other TypeBox schemas.
converts before checking: "42" becomes 42, a single "a" becomes ["a"]
params, query, headers and form fields, which arrive as strings
clean
drops properties the schema does not declare
response DTOs, so undeclared fields do not leak
defaults
fills in a declared default when a value is missing
optional query parameters, configuration
issues
"detailed" (default) or "summary"
"summary" reports a single issue with no path, much cheaper on large bodies
vendor
the vendor name reported through Standard Schema
custom tooling
All are off by default. None of them changes the value passed in: they work
on a copy, so clean on a response never deletes fields from the object the
handler returned.
convert is never implicit. A JSON body that sends "42" for a number is
refused.
defaults runs before the check, so a filled-in default is checked like any
other value. In the OpenAPI document, a property with a default is optional
on input and required on output.
issues: "summary" returns one issue, does not match the schema, with no
path. Finding where a value failed is TypeBox’s slow path, and its cost grows
with the valid data before the failure. Use "summary" on large bodies on
public endpoints, where a bad payload only needs a 422.
Wrapping a DTO again replaces its options.
tb(CreateOrder, { issues: "summary" }) has no convert, even if
CreateOrder had it.
A DTO nested in another is checked with the outer DTO’s options, not its
own. If the nested one has an option the outer one lacks, tb() throws,
because a clean that no longer strips would leak fields:
const
constPublicUser:TypeBoxSchema<Type.TObject<{
id:Type.TString;
}>>
PublicUser=tb(Type.Object({
id: Type.TString
id: Type.String() }), {
clean?: boolean |undefined
Strip properties the schema does not declare (Value.Clean).
The barrier against leaking extra fields through a response DTO,
where structural typing alone cannot help.
The validated value is a clone: TypeBox strips in place, so without a
copy the first response would delete the undeclared fields from the
handler's own object — a cached entity or a store record.
clean: true });
tb(Type.Object({
users: Type.TArray<TypeBoxSchema<Type.TObject<{
id:Type.TString;
}>>>
users: Type.Array(
constPublicUser:TypeBoxSchema<Type.TObject<{
id:Type.TString;
}>>
PublicUser) }));
// throws: the nested DTO asks for clean, which the outer one does not have
const
constUsers:TypeBoxSchema<Type.TObject<{
users:Type.TArray<TypeBoxSchema<Type.TObject<{
id:Type.TString;
}>>>;
}>>
Users=tb(Type.Object({
users: Type.TArray<TypeBoxSchema<Type.TObject<{
id:Type.TString;
}>>>
users: Type.Array(
constPublicUser:TypeBoxSchema<Type.TObject<{
id:Type.TString;
}>>
PublicUser) }), {
clean?: boolean |undefined
Strip properties the schema does not declare (Value.Clean).
The barrier against leaking extra fields through a response DTO,
where structural typing alone cannot help.
The validated value is a clone: TypeBox strips in place, so without a
copy the first response would delete the undeclared fields from the
handler's own object — a cached entity or a store record.
clean: true });
// strips every user
A schema derived from a DTO, with Type.Pick, Type.Omit or
Type.Partial, is a new schema with none of the DTO’s options. Pass the
options it needs to its own tb().
file() and files() validate uploads in a bodyType: "form" body. See
Request bodies for how uploads are read.
import { file, files, tb, Type } from"@tetsujs/typebox";
const
constUpload:TypeBoxSchema<Type.TObject<{
title:Type.TString;
avatar:Type.TUnsafe<File>;
gallery:Type.TUnsafe<File[]>;
}>>
Upload=tb(
Type.Object({
title: Type.TString
title: Type.String({
minLength?: number |undefined
Specifies the minimum number of characters allowed in the string.
Must be a non-negative integer.
minLength: 1 }),
avatar: Type.TUnsafe<File>
avatar: file({
maxSize?: FileSize |undefined
Largest accepted size.
maxSize: "5m",
type?: string | readonly string[] |undefined
Accepted MIME types. A bare prefix matches a family: "image" accepts
image/png and image/webp, "image/png" accepts only that one.
type: "image" }),
gallery: Type.TUnsafe<File[]>
gallery: files({
maxSize?: FileSize |undefined
Largest accepted size.
maxSize: "1m" }),
}),
);
exportconstuploads=controller("Uploads", () => ({
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: "/uploads"
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: "/uploads",
bodyType?:"form"|undefined
The wire shape of the request body: "json" (the default), "form",
"text", or "stream" for a ReadableStream the handler reads itself,
counted against maxBodySize.
Declaring it makes the body be read even without a body schema — a
form of files alone needs no schema to be parsed. The declaration also
decides how the bytes are parsed: the content-type header is never
consulted, so a body that does not parse as the declared shape is a
400 rather than a silent reinterpretation.
bodyType: "form",
schema?: {
readonly body: TypeBoxSchema<Type.TObject<{
title:Type.TString;
avatar:Type.TUnsafe<File>;
gallery:Type.TUnsafe<File[]>;
}>>;
} |undefined
Validation schemas; an absent part is neither parsed nor typed.
the MIME type the client declared, not the bytes; "image" matches every image type
A size is a number of bytes, or a number with k (KiB) or m (MiB).
files() gives an array whenever the field is there, even for a single
file. A file input left empty is absent from the body, so wrap an optional
one in Type.Optional: an absent files() field is undefined, not [],
and the handler reads ctx.body.gallery ?? [].
These checks run after the body is read. The limit on how much is read at
all is maxBodySize on the application, and it counts the multipart framing
too. In the OpenAPI document a file is a binary string with its size limits
and media type.
Set your own message with errorMessage: one string for any failure, or one
per keyword:
const
constCreateUser:TypeBoxSchema<Type.TObject<{
email:Type.TString;
password:Type.TString;
}>>
CreateUser=tb(
Type.Object({
email: Type.TString
email: Type.String({
format?: Type.TFormat |undefined
Specifies the expected string format. May also be a custom format string.
format: "email",
errorMessage: {
format: string;
required: string;
}
errorMessage: {
format: string
format: "Not an email address",
required: string
required: "Email is required" },
}),
password: Type.TString
password: Type.String({
minLength?: number |undefined
Specifies the minimum number of characters allowed in the string.
Must be a non-negative integer.
minLength: 8,
errorMessage: string
errorMessage: "At least 8 characters" }),
}),
);
errorMessage is left out of the JSON Schema and the OpenAPI document. For
several languages, keep schemas without messages and translate in an
onError hook, by each issue’s path. See Errors.
Each issue points at the field itself: a missing password is reported at
["body", "password"], not at body. A union of literals fails with one
issue that lists the allowed values.
parse() validates a value and returns it, or throws a ValidationError
with every issue. It is synchronous, so it works at module level, for
example for the environment:
const
constEnv:TypeBoxSchema<Type.TObject<{
PORT:Type.TInteger;
DATABASE_URL:Type.TString;
}>>
Env=tb(
Type.Object({
typePORT: Type.TInteger
PORT: Type.Integer({
minimum?: number | bigint |undefined
Specifies an inclusive lower limit for the number (number must be greater than or equal to this value).
minimum: 1,
maximum?: number | bigint |undefined
Specifies an inclusive upper limit for the number (number must be less than or equal to this value).
maximum: 65_535,
default?: unknown
A default value for the data, used when no value is provided.
default: 3000 }),
typeDATABASE_URL: Type.TString
DATABASE_URL: Type.String({
format?: Type.TFormat |undefined
Specifies the expected string format. May also be a custom format string.
Required for params, query and headers, whose values always
arrive as strings. Off by default — coercion is never implicit.
The validated value is a clone: TypeBox coerces in place, and on a
response schema the argument is the object the handler returned.
convert: true,
defaults?: boolean |undefined
Fill in the default a schema declares for a value that is absent
(Value.Default).
Off by default, like every other mutation: a body that omits a field
and a body that sends the default are different requests, and only the
author knows whether the difference matters. Where it does not — a
limit on a query, a port in an environment — this is what spares the
handler a ?? 20.
Runs before coercion, so a default is checked exactly like a value
that arrived.
Also changes what the schema says, not only what it accepts: a
property with a default is left out of required in the emitted
input schema, since the client need not send it.
defaults: true,
clean?: boolean |undefined
Strip properties the schema does not declare (Value.Clean).
The barrier against leaking extra fields through a response DTO,
where structural typing alone cannot help.
The validated value is a clone: TypeBox strips in place, so without a
copy the first response would delete the undeclared fields from the
handler's own object — a cached entity or a store record.
clean: true },
);
exportconst
constenv: {
PORT:number;
DATABASE_URL:string;
}
env=parse(
constEnv:TypeBoxSchema<Type.TObject<{
PORT:Type.TInteger;
DATABASE_URL:Type.TString;
}>>
Env, Bun.
constenv:Bun.Env&NodeJS.ProcessEnv&ImportMetaEnv
The environment variables of the process
Defaults to process.env as it was when the current Bun process launched.
Changes to process.env at runtime won't automatically be reflected in the default value. For that, you can pass process.env explicitly.
env);
Inside a handler, a thrown ValidationError becomes the same 422 a
rejected request gets. At startup, catch it, print error.issues and exit.
A TypeBox schema is JSON Schema 2020-12, which OpenAPI 3.1 uses as is, so
@tetsujs/openapi needs no converter. Only the
targets draft-2020-12 and openapi-3.1 are supported, exported as
supportedTargets. Asking for an older dialect throws.
TypeBox compiles each schema into a checking function, so valid bodies are
checked several times faster than with other Standard Schema libraries, and
the gap grows with the body. Describing a failure in detail is its slow
path, which issues: "summary" avoids, and importing it costs more memory
than Zod or Valibot. TypeBox suits large bodies, mostly valid traffic and
schemas that double as documentation; a lighter library suits cases where
memory and startup matter more. Performance
has the numbers.