コンテンツへスキップ

Cloudflare Workers × Hono プロジェクトで Zod から Valibot に移行してバンドルサイズを89%削減した

Cloudflare Workers × Hono プロジェクトで Zod から Valibot に移行してバンドルサイズを89%削減した話 のアイキャッチ
Contents

    はじめに

    PWA(Progressive Web App)の開発において、バンドルサイズはユーザー体験に直結する重要な指標です。特にモバイルユーザーや低速回線環境では、数十KBの差がアプリの初回表示時間に大きく影響します。

    今回、サプリメント摂取管理アプリ「DailyDose」のAPIバックエンド(Cloudflare Workers + Hono)で使用していたスキーマバリデーションライブラリを Zod から Valibot に移行し、バンドルサイズを約89%削減することに成功しました。

    この記事では、移行の動機、具体的な変換パターン、そして移行時の注意点を解説します。

    移行の動機:バンドルサイズの比較

    ライブラリMinifiedGzipped
    Zod~59KB~14KB
    Valibot~6KB~2KB

    約89%のサイズ削減を実現できます。

    この差が生まれる理由は、両ライブラリの設計思想の違いにあります:

    • Zod はメソッドチェーンによる直感的なAPIで、全機能がバンドルされる
    • Valibot は関数合成ベースのモジュラー設計で、使用する機能のみがtree-shakingで抽出される

    Cloudflare Workersには1MBのサイズ制限があるため、この差は特に重要です。

    技術スタック

    移行対象のプロジェクト構成:

    • ランタイムは Cloudflare Workers
    • フレームワークは Hono
    • バリデーションは Zod から Valibot へ移行
    • Hono ミドルウェアは @hono/zod-validator から @hono/valibot-validator へ移行

    変換パターン早見表

    実際のコードで使用した変換パターンをまとめます。

    基本的な型定義

    ZodValibot
    z.object({})v.object({})
    z.string()v.string()
    z.number()v.number()
    z.boolean()v.boolean()
    z.array(z.string())v.array(v.string())

    バリデーションルール

    Zodではメソッドチェーンで記述しますが、Valibotでは v.pipe() で関数を合成します。

    ZodValibot
    z.string().min(1)v.pipe(v.string(), v.minLength(1))
    z.string().max(100)v.pipe(v.string(), v.maxLength(100))
    z.string().email()v.pipe(v.string(), v.email())
    z.string().uuid()v.pipe(v.string(), v.uuid())
    z.string().url()v.pipe(v.string(), v.url())
    z.string().regex(/pat/)v.pipe(v.string(), v.regex(/pat/))

    オプショナル・デフォルト値

    ZodValibot
    z.string().optional()v.optional(v.string())
    z.string().nullable()v.nullable(v.string())
    z.string().default('foo')v.optional(v.string(), 'foo')
    z.string().optional().nullable()v.optional(v.nullable(v.string()))

    Enum・リテラル

    ZodValibot
    z.enum(['a', 'b', 'c'])v.picklist(['a', 'b', 'c'])
    z.literal('value')v.literal('value')

    レコード型

    ZodValibot
    z.record(z.string(), z.unknown())v.record(v.string(), v.unknown())

    実際の移行例

    例1: シンプルなログインスキーマ

    Before (Zod)

    import { z } from 'zod';
    import { zValidator } from '@hono/zod-validator';
    const loginSchema = z.object({
    email: z.string().email(),
    password: z.string(),
    });
    app.post('/login', zValidator('json', loginSchema), async (c) => {
    const { email, password } = c.req.valid('json');
    // ...
    });

    After (Valibot)

    import * as v from 'valibot';
    import { vValidator } from '@hono/valibot-validator';
    const loginSchema = v.object({
    email: v.pipe(v.string(), v.email()),
    password: v.string(),
    });
    app.post('/login', vValidator('json', loginSchema), async (c) => {
    const { email, password } = c.req.valid('json');
    // ...
    });

    例2: 複雑なパスワードバリデーション

    Before (Zod)

    const passwordSchema = z
    .string()
    .min(12, 'Password must be at least 12 characters')
    .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
    .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
    .regex(/[0-9]/, 'Password must contain at least one number')
    .regex(
    /[^A-Za-z0-9]/,
    'Password must contain at least one special character',
    );

    After (Valibot)

    const passwordSchema = v.pipe(
    v.string(),
    v.minLength(12, 'Password must be at least 12 characters'),
    v.regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),
    v.regex(/[a-z]/, 'Password must contain at least one lowercase letter'),
    v.regex(/[0-9]/, 'Password must contain at least one number'),
    v.regex(
    /[^A-Za-z0-9]/,
    'Password must contain at least one special character',
    ),
    );

    例3: オプショナルフィールドとデフォルト値

    Before (Zod)

    const createLogSchema = z.object({
    scheduleId: z.string().uuid(),
    date: z
    .string()
    .regex(/^\d{4}-\d{2}-\d{2}$/)
    .optional(),
    status: z.enum(['taken', 'skipped']).default('taken'),
    skipReason: z.enum(['injury', 'sick', 'other']).optional(),
    skipNote: z.string().max(200).optional(),
    });

    After (Valibot)

    const createLogSchema = v.object({
    scheduleId: v.pipe(v.string(), v.uuid()),
    date: v.optional(v.pipe(v.string(), v.regex(/^\d{4}-\d{2}-\d{2}$/))),
    status: v.optional(v.picklist(['taken', 'skipped']), 'taken'),
    skipReason: v.optional(v.picklist(['injury', 'sick', 'other'])),
    skipNote: v.optional(v.pipe(v.string(), v.maxLength(200))),
    });

    Honoとの統合

    Honoでは @hono/valibot-validator パッケージを使用することで、Zodと同じ使用感でValibotを利用できます。

    import { Hono } from 'hono';
    import * as v from 'valibot';
    import { vValidator } from '@hono/valibot-validator';
    const app = new Hono();
    // JSONボディのバリデーション
    app.post('/api/users', vValidator('json', userSchema), (c) => {
    const data = c.req.valid('json');
    // ...
    });
    // クエリパラメータのバリデーション
    app.get('/api/stats', vValidator('query', querySchema), (c) => {
    const { period, date } = c.req.valid('query');
    // ...
    });
    // URLパラメータのバリデーション
    app.get('/api/items/:id', vValidator('param', paramSchema), (c) => {
    const { id } = c.req.valid('param');
    // ...
    });

    移行時の注意点

    1. pipe() の理解

    Valibotの最大の特徴は v.pipe() による関数合成です。Zodのメソッドチェーンに慣れていると最初は違和感がありますが、この設計がtree-shakingを可能にしています。

    // Zodのメソッドチェーン
    z.string().min(1).max(100).email();
    // Valibotの関数合成
    v.pipe(v.string(), v.minLength(1), v.maxLength(100), v.email());

    2. enum → picklist

    Zodの z.enum() は Valibotでは v.picklist() に対応します。名前が異なるので注意が必要です。

    3. デフォルト値の書き方

    Zodでは .default() メソッドを使いますが、Valibotでは v.optional() の第2引数にデフォルト値を渡します。

    // Zod
    z.string().default('default');
    // Valibot
    v.optional(v.string(), 'default');

    4. nullable と optional の順序

    両方を組み合わせる場合、Valibotでは外側から内側へネストします。

    // Zod
    z.string().optional().nullable();
    // Valibot
    v.optional(v.nullable(v.string()));

    テスト戦略

    移行の成功を確認するため、既存のAPIテストを活用しました。

    Terminal window
    # 全テストを実行
    pnpm test:run
    # 結果: 171 tests passed

    スキーマバリデーションの振る舞いはAPIのレスポンスに直接影響するため、既存のインテグレーションテストが通れば機能的な互換性が担保されます。

    また、TypeScriptの型チェックも重要な検証手段です。

    Terminal window
    pnpm typecheck

    Valibotは型推論が優れており、Zodと同等の型安全性を提供します。


    移行の手順まとめ

    flowchart LR
        subgraph step1["1️⃣ 依存関係"]
            Z1[zod] -.->|remove| X1[❌]
            Z2["@hono/zod-validator"] -.->|remove| X2[❌]
            V1[valibot] -->|add| OK1[✅]
            V2["@hono/valibot-validator"] -->|add| OK2[✅]
        end
    
        subgraph step2["2️⃣ インポート変更"]
            I1["import { z }"] -->|変換| I2["import * as v"]
            I3[zValidator] -->|変換| I4[vValidator]
        end
    
        subgraph step3["3️⃣ スキーマ変換"]
            S1[".method()"] -->|変換| S2["v.pipe()"]
            S3["z.enum()"] -->|変換| S4["v.picklist()"]
        end
    
        subgraph step4["4️⃣ テスト実行"]
            T1[pnpm test] --> T2{Pass?}
            T2 -->|Yes| T3[🎉 完了]
            T2 -->|No| T4[修正]
            T4 --> T1
        end
    
        step1 --> step2 --> step3 --> step4
    

    1. 依存関係の更新

    Terminal window
    pnpm remove zod @hono/zod-validator
    pnpm add valibot @hono/valibot-validator

    2. インポートの変更

    // Before
    import { z } from 'zod';
    import { zValidator } from '@hono/zod-validator';
    // After
    import * as v from 'valibot';
    import { vValidator } from '@hono/valibot-validator';

    3. スキーマの変換(この記事のパターン表を参照)

    4. テストの実行で動作確認


    結果

    • 移行ファイル数は11ファイル
    • テストは171件すべてパス
    • バンドルサイズ削減は約53KB(89%削減)
    • API互換性は完全維持

    まとめ

    ZodからValibotへの移行は、特にバンドルサイズに敏感なエッジ環境(Cloudflare Workers)やPWAにおいて大きなメリットがあります。

    移行のポイント:

    • v.pipe() による関数合成パターンを理解する
    • enumpicklist などの名前の違いに注意
    • 既存テストを活用して互換性を確認

    Valibotは積極的に開発が進んでおり、Zodに匹敵する機能を提供しながらも軽量さを維持しています。新規プロジェクトでは最初からValibotを採用することも検討に値するでしょう。


    参考リンク

    X Facebook B! Hatena