はじめに
「TypeScriptの型は書いた。バリデーションスキーマも書いた。ついでにOpenAPI用のJSON Schemaも書いた」——気づけば同じデータ構造を3回定義していた、という経験はありませんか?
ランタイムバリデーションといえばZodが定番ですが、ZodのスキーマはZod独自のオブジェクトです。OpenAPIドキュメントの生成やAPIゲートウェイとの連携でJSON Schemaが必要になると、変換レイヤーを挟むことになります。
今回紹介するTypeBoxは、このアプローチを逆転させたライブラリです。スキーマを定義すると、それ自体が標準準拠のJSON Schemaオブジェクトになり、同時にTypeScriptの型としても推論されます。つまり「型」「バリデーション」「JSON Schema」の3つが、たった1つの定義から手に入るのです。
とはいえ、説明を読むより実際に動かした方が早いはず。ブラウザ上でType.Objectによるスキーマ定義とValue.Checkによるリアルタイムバリデーションを試せるサンプルを用意したので、先に手を動かしたい方はこちらからどうぞ。
TypeBoxとは
TypeBoxは「JSON Schema Type Builder with Static Type Resolution for TypeScript」を掲げるライブラリです。メモリ上にJSON Schemaオブジェクトを構築し、それをTypeScriptの静的型として推論できるランタイム型システムを提供します。
FastifyやElysiaといった人気フレームワークの型システムの基盤としても採用されており、サーバーサイドTypeScriptの世界では実績十分です。
主な特徴
- JSON Schemaがファーストクラス - 定義したスキーマはそのままJSON Schema(Draft 2020-12対応)として扱えます。OpenAPI生成や他言語との共有に変換不要です
- 静的型推論 -
Type.StaticでスキーマからTypeScriptの型を取り出せます。型とスキーマの二重管理から解放されます - 圧倒的なパフォーマンス - JITコンパイラを備えており、公式ベンチマークではオブジェクト検証で秒間7,800万回と、定番のAjvを上回る速度を記録しています
- 依存関係ゼロ - 軽量で、Node.js・Deno・Bun・ブラウザのどこでも動作します
インストール
TypeBox 1.x はnpmから一発でインストールできます。
# npm
npm install typebox
# pnpm
pnpm add typebox
# bun
bun add typebox
なお、1.x はESM専用・TypeScript 6.0以上が対象です。CommonJS環境や既存プロジェクトでは、Long Term Support版である 0.x 系(パッケージ名は @sinclair/typebox)も引き続き利用できます。
# LTS版(Fastifyなどのエコシステムで広く使われています)
npm install @sinclair/typebox
TypeBoxのサンプルを動かす
TypeBoxは依存関係ゼロで、Node.jsだけでなくブラウザ単体でもそのまま動作します。ここではLTS版の@sinclair/typeboxを使い、Type.Objectで定義したスキーマを@sinclair/typebox/valueのValue.Checkで検証し、フォームの入力に応じてリアルタイムに結果が切り替わるサンプルを用意しました。
名前・メールアドレス・年齢の3項目を持つスキーマに対して、入力のたびにValue.CheckとValue.Errorsを実行し、エラーがあれば項目ごとのメッセージを表示します。まずは要点だけを抜き出したコードがこちらです。
import { Type } from '@sinclair/typebox'
import { Value } from '@sinclair/typebox/value'
const User = Type.Object({
name: Type.String({ minLength: 1 }),
email: Type.String({ pattern: '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$' }),
age: Type.Integer({ minimum: 0, maximum: 150 }),
})
Value.Check(User, { name: 'Taro', email: 'taro@example.com', age: 24 })
// => true
Value.Check(User, { name: '', email: 'invalid', age: -1 })
// => false
for (const error of Value.Errors(User, { name: '', email: 'invalid', age: -1 })) {
console.log(error.path, error.message)
}
実際に入力欄を書き換えて挙動を確かめられるのが下のサンプルです。名前欄を空にしたり、メールアドレスから@を消したり、年齢を-1にしたりすると、Value.Errorsが返すエラーメッセージがその場で切り替わります。
初期値はすべて条件を満たしているので✅ すべての項目が有効ですと表示されます。名前欄を空にするとType.String({ minLength: 1 })の制約に反してStringMinLengthのエラーが、メールアドレスから@を消すとpattern違反によるStringPatternのエラーが、年齢欄を-1にするとType.Integer({ minimum: 0 })のIntegerMinimumエラーが、それぞれValue.Errorsから返ってくるのが分かります。
基本的な使い方
まずはスキーマを定義して、型を取り出してみましょう。
import Type from 'typebox'
// スキーマを定義する
const User = Type.Object({
id: Type.String(),
name: Type.String(),
email: Type.String({ format: 'email' }),
age: Type.Optional(Type.Number({ minimum: 0 }))
})
// スキーマからTypeScriptの型を推論する
type User = Type.Static<typeof User>
// type User = {
// id: string
// name: string
// email: string
// age?: number
// }
ここで重要なのは、User が特別なオブジェクトではなく素のJSON Schemaだという点です。console.logで覗いてみると、見慣れた形式がそのまま出てきます。
console.log(JSON.stringify(User, null, 2))
// {
// "type": "object",
// "properties": {
// "id": { "type": "string" },
// "name": { "type": "string" },
// ...
// },
// "required": ["id", "name", "email"]
// }
このオブジェクトをそのままOpenAPIの定義に埋め込んだり、ファイルに書き出して他のシステムと共有したりできます。これがZodなど独自スキーマ形式のライブラリとの最大の違いです。
バリデーションを実行する
検証には typebox/schema モジュールを使います。Compile でスキーマをJITコンパイルすると、超高速なバリデータが得られます。
import Type from 'typebox'
import Schema from 'typebox/schema'
const Vector = Schema.Compile(Type.Object({
x: Type.Number(),
y: Type.Number(),
z: Type.Number()
}))
// Check: 真偽値で判定する(型ガードとして機能)
const valid = Vector.Check({ x: 1, y: 0, z: 0 }) // true
// Parse: 検証して型付きの値を返す(不正な値なら例外)
const value = Vector.Parse({ x: 1, y: 0, z: 0 })
// value は { x: number, y: number, z: number } 型
Check は型ガードとして働くため、if 文の中では自動的に型が絞り込まれます。ホットパスで何百万回と呼ばれるAPIサーバーのバリデーションでも、コンパイル済みバリデータなら安心です。
TypeScript構文でスキーマを書く
TypeBox 1.x の目玉機能が Script です。なんと、TypeScriptの型構文をそのまま文字列で書くと、実行時にJSON Schemaへ変換してくれます。
import Type from 'typebox'
const Types = Type.Script(`
type Vector2 = { x: number, y: number }
type Vector3 = { x: number, y: number, z: number }
`)
// Types.Vector3 はJSON Schemaとして利用可能
type Vector3 = Type.Static<typeof Types.Vector3>
Conditional TypesやMapped Typesといった高度な型レベル構文にも対応しており、「TypeScriptの型定義がそのままランタイムスキーマになる」体験は一度味わうと戻れません。
実践的なユースケース
Fastifyで型安全なAPIを作る
TypeBoxが最も輝くのがFastifyとの組み合わせです。LTS版の @sinclair/typebox と型プロバイダを使うと、ルート定義のスキーマからリクエスト・レスポンスの型が自動で推論されます。
npm install fastify @fastify/type-provider-typebox @sinclair/typebox
import Fastify from 'fastify'
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import { Type } from '@sinclair/typebox'
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>()
app.post('/users', {
schema: {
body: Type.Object({
name: Type.String({ minLength: 1 }),
email: Type.String({ format: 'email' })
}),
response: {
201: Type.Object({
id: Type.String(),
name: Type.String()
})
}
}
}, async (request, reply) => {
// request.body は { name: string, email: string } と推論済み
const { name } = request.body
reply.code(201)
return { id: crypto.randomUUID(), name }
})
app.listen({ port: 3000 })
スキーマはFastify内部で高速バリデータにコンパイルされ、不正なリクエストは自動で弾かれます。しかも同じスキーマから @fastify/swagger でOpenAPIドキュメントまで生成できるので、「型・検証・APIドキュメント」が完全に一元化されます。
フロントエンドとバックエンドでスキーマを共有する
スキーマが標準JSON Schemaなので、モノレポで共通パッケージに置けばそのまま両側で使えます。
// packages/shared/schemas.ts
import Type from 'typebox'
export const CreatePostInput = Type.Object({
title: Type.String({ minLength: 1, maxLength: 100 }),
body: Type.String({ minLength: 1 }),
tags: Type.Array(Type.String(), { maxItems: 5 })
})
export type CreatePostInput = Type.Static<typeof CreatePostInput>
// フロントエンド側: フォーム送信前のバリデーション
import Schema from 'typebox/schema'
import { CreatePostInput } from '@myapp/shared/schemas'
const validator = Schema.Compile(CreatePostInput)
function handleSubmit(formData: unknown) {
if (validator.Check(formData)) {
// ここでは formData が CreatePostInput 型に絞り込まれている
console.log(`「${formData.title}」を投稿します`)
} else {
console.warn('入力内容に誤りがあります')
}
}
バックエンドでも同じ CreatePostInput をFastifyのスキーマに渡すだけです。バリデーションルールの「フロントとバックで微妙に違う」問題が構造的に発生しなくなります。
TypeBoxでネストしたスキーマ・配列をリアルタイム検証する
TypeBoxのスキーマはType.ObjectやType.Arrayを組み合わせるだけで、ネストしたオブジェクトや配列もそのまま表現できます。表示名・住所オブジェクト・タグの配列を持つプロフィール情報を例に、入れ子になった構造をValue.Checkでまとめて検証するサンプルです。
import { Type } from '@sinclair/typebox'
import { Value } from '@sinclair/typebox/value'
const Profile = Type.Object({
displayName: Type.String({ minLength: 1 }),
address: Type.Object({
city: Type.String({ minLength: 1 }),
zip: Type.String({ pattern: '^[0-9]{3}-?[0-9]{4}$' }),
}),
tags: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 5 }),
})
Value.Check(Profile, {
displayName: 'Hanako',
address: { city: 'Tokyo', zip: '150-0001' },
tags: ['frontend', 'typescript'],
}) // => true
表示名・市区町村・郵便番号・タグ(カンマ区切り)を書き換えると、ネストしたaddressや配列のtagsまで含めて検証結果が更新されます。
郵便番号を150のようにハイフン・下4桁なしの形にするとaddress/zipのStringPatternエラーが出ます。タグ欄を空にするとType.Array(..., { minItems: 1 })の制約に反してtagsのArrayMinItemsエラーが表示され、6個以上に増やすとArrayMaxItemsエラーに切り替わります。
TypeBoxのエラーメッセージをカスタマイズする
標準のエラーメッセージはExpected stringのような英語です。@sinclair/typebox/errorsのSetErrorFunctionを使うと、ValueErrorTypeごとにメッセージを差し替えられます。パスワード入力を例に、StringMinLengthとStringPatternのエラーを日本語メッセージへ置き換えるサンプルです。
import { Type } from '@sinclair/typebox'
import { Value } from '@sinclair/typebox/value'
import { SetErrorFunction, DefaultErrorFunction, ValueErrorType } from '@sinclair/typebox/errors'
SetErrorFunction((error) => {
switch (error.errorType) {
case ValueErrorType.StringMinLength:
return '8文字以上で入力してください'
case ValueErrorType.StringPattern:
return '半角英数字と -_ のみ使用できます'
default:
return DefaultErrorFunction(error)
}
})
const Password = Type.String({ minLength: 8, pattern: '^[A-Za-z0-9_-]+$' })
パスワード欄の値を書き換えると、SetErrorFunctionで登録したメッセージがそのまま画面に反映されます。
パスワードを7文字以下に削るとStringMinLengthのケースに切り替わり「8文字以上で入力してください」という日本語メッセージが表示されます。!や空白のような[A-Za-z0-9_-]以外の文字を含めるとStringPatternのメッセージに切り替わります。SetErrorFunctionはアプリ全体に効くグローバル設定なので、エラーメッセージのローカライズを1箇所で一括管理できるのが利点です。
Zodとの使い分け
どちらも優れたライブラリなので、プロジェクトの性質で選ぶのがおすすめです。
- TypeBoxが向いているケース - JSON Schema/OpenAPIが絡む、FastifyやElysiaを使っている、バリデーションが性能ボトルネックになり得る大規模API
- Zodが向いているケース -
transformなどの柔軟な変換処理を多用したい、フロントエンド中心でエコシステム(React Hook Formなど)の連携を重視したい
「スキーマの資産を標準形式で持ちたいか」が判断の分かれ目になります。
まとめ
TypeBoxは、TypeScriptの型・ランタイムバリデーション・JSON Schemaという3つの世界を、1つの定義で結びつけてくれるライブラリです。
- スキーマ定義がそのまま標準JSON Schemaになる
Type.Staticで型の二重管理が不要になる- JITコンパイルによりAjvを超える検証速度を発揮する
- Fastifyとの組み合わせで型安全なAPIとドキュメント生成まで一気通貫
とくにOpenAPIを中心に据えたAPI開発では、変換ライブラリを挟まず標準形式で完結する気持ちよさは格別です。まずは手元のAPIのバリデーション1本から、TypeBoxに置き換えてみてはいかがでしょうか。
