Skip to content

@tetsujs/typebox

@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
bun add typebox @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.

Wrap a TypeBox schema with tb() and use it in a route’s schema:

import { tb, Type } from "@tetsujs/typebox";
export const
const CreateUser: TypeBoxSchema<Type.TObject<{
name: Type.TString;
email: Type.TString;
}>>
CreateUser
= tb(
Type.Object({
name: Type.TString
name
: Type.String({
minLength?: number | undefined

Specifies the minimum number of characters allowed in the string. Must be a non-negative integer.

minLength
: 1 }),
email: Type.TString
email
: Type.String({
format?: Type.TFormat | undefined

Specifies the expected string format. May also be a custom format string.

format
: "email" }),
}),
);
export const
const UserParams: TypeBoxSchema<Type.TObject<{
id: Type.TInteger;
}>>
UserParams
= tb(Type.Object({
id: Type.TInteger
id
: Type.Integer() }), {
convert?: boolean | undefined

Coerce input before checking (Value.Convert): "42" becomes 42, "true" becomes true.

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 });
export const usersController = controller("Users", () => ({
update: route({
method: "PUT"

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
: "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
:
const UserParams: TypeBoxSchema<Type.TObject<{
id: Type.TInteger;
}>>
UserParams
,
body: TypeBoxSchema<Type.TObject<{
name: Type.TString;
email: Type.TString;
}>>
body
:
const CreateUser: 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
) =>
const users: {
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.

Option Effect Use for
convert 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
const PublicUser: 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(
const PublicUser: TypeBoxSchema<Type.TObject<{
id: Type.TString;
}>>
PublicUser
) }));
// throws: the nested DTO asks for clean, which the outer one does not have
const
const Users: 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(
const PublicUser: 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
const Upload: 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" }),
}),
);
export const uploads = 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.

schema
: {
body: TypeBoxSchema<Type.TObject<{
title: Type.TString;
avatar: Type.TUnsafe<File>;
gallery: Type.TUnsafe<File[]>;
}>>
body
:
const Upload: TypeBoxSchema<Type.TObject<{
title: Type.TString;
avatar: Type.TUnsafe<File>;
gallery: Type.TUnsafe<File[]>;
}>>
Upload
},
handler: (
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
avatar: File;
gallery: File[];
};
}
ctx
) => store(
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
avatar: File;
gallery: File[];
};
}
ctx
.
body: {
title: string;
avatar: File;
gallery: File[];
}
body
.
title: string
title
,
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
avatar: File;
gallery: File[];
};
}
ctx
.
body: {
title: string;
avatar: File;
gallery: File[];
}
body
.avatar,
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
avatar: File;
gallery: File[];
};
}
ctx
.
body: {
title: string;
avatar: File;
gallery: File[];
}
body
.
gallery: File[]
gallery
),
avatar: File
}),
}));
Option Accepts Checks
maxSize 5242880, "512k", "5m" the largest file size
minSize the same the smallest; 1 rejects an empty file
type "image", "image/png", ["image", "application/pdf"] 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
const CreateUser: 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.

A Type.Codec is validated as it arrives and handed to the handler decoded. The validated type is the decoded one:

const
const Instant: Type.TCodec<Type.TString, Date>
Instant
= Type.Codec(Type.String({
format?: Type.TFormat | undefined

Specifies the expected string format. May also be a custom format string.

format
: "date-time" }))
.Decode((
value: string
value
) => new Date(
value: string
value
))
.Encode((
value: Date
value
: Date) =>
value: Date
value
.toISOString());
const
const Stored: TypeBoxSchema<Type.TObject<{
code: Type.TString;
expiresAt: Type.TCodec<Type.TString, Date>;
}>>
Stored
= tb(Type.Object({
code: Type.TString
code
: Type.String(),
expiresAt: Type.TCodec<Type.TString, Date>
expiresAt
:
const Instant: Type.TCodec<Type.TString, Date>
Instant
}));
const session = parse(
const Stored: TypeBoxSchema<Type.TObject<{
code: Type.TString;
expiresAt: Type.TCodec<Type.TString, Date>;
}>>
Stored
, await
const redis: {
hgetall(key: string): Promise<Record<string, string>>;
}
redis
.hgetall(
const key: string
key
));
const session: {
code: string;
expiresAt: Date;
}

If Decode throws, the value fails with a 422 carrying the error’s message, not a 500. Throw a message meant for the client.

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
const Env: TypeBoxSchema<Type.TObject<{
PORT: Type.TInteger;
DATABASE_URL: Type.TString;
}>>
Env
= tb(
Type.Object({
type PORT: 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 }),
type DATABASE_URL: Type.TString
DATABASE_URL
: Type.String({
format?: Type.TFormat | undefined

Specifies the expected string format. May also be a custom format string.

format
: "uri" }),
}),
{
convert?: boolean | undefined

Coerce input before checking (Value.Convert): "42" becomes 42, "true" becomes true.

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 },
);
export const
const env: {
PORT: number;
DATABASE_URL: string;
}
env
= parse(
const Env: TypeBoxSchema<Type.TObject<{
PORT: Type.TInteger;
DATABASE_URL: Type.TString;
}>>
Env
, Bun.
const env: 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.