コンテンツへスキップ

バンドルサイズ1.5KBのValibotで型安全なフルスタック開発 - モノレポAPIスキーマ共有パターン

バンドルサイズ1.5KBのValibotで型安全なフルスタック開発 - モノレポAPIスキーマ共有パターン のアイキャッチ
Contents

    「APIのレスポンス型が変わったのに、フロントエンドで気づかず本番でクラッシュ」

    フルスタック開発者なら一度は経験したことがあるはずです。

    この記事では、Valibotを使ってバックエンド(Hono)とフロントエンド(React Native)間でAPIスキーマ共有による型安全性を保証しながら、バンドルサイズを最小化する設計パターンを解説します。Zodの14KB程度の負荷から解放される、開発体験を実装例で紹介します。

    TL;DR

    • ValibotはZodより90%軽量なバリデーションライブラリ
    • モノレポでの型共有として、packages/sharedにスキーマを集約しAPI・Mobile両方で利用する
    • Honoとの統合は@hono/valibot-validatorでリクエストバリデーションを型安全にする
    • 型の自動生成はv.InferOutputでスキーマから型を抽出するため、手動の型定義は不要

    この設計を実装した筋トレ記録アプリ Attain を公開中。 👉 アプリを見る


    Zodとの比較 - なぜValibotなのか

    バンドルサイズの決定的な差

    フルスタックTypeScriptの定番はZodですが、モバイルアプリではバンドルサイズが致命的です。

    ライブラリmin+gzip特徴
    Zod~14.5 KB豊富なエコシステム
    Valibot~1.5 KBTree-shakable、モジュラー設計

    Valibotは使った機能だけがバンドルに含まれます。 React Nativeアプリでは、この差がユーザーの初回起動速度に直結します。

    API設計の工夫 - パイプ構文がTree-shakingを実現

    import * as v from 'valibot';
    // Zodスタイル(メソッドチェーン)ではなく、パイプで構成
    const EmailSchema = v.pipe(v.string(), v.email(), v.maxLength(255));

    最初は違和感がありますが、Tree-shaking効率のための設計です。 使わないバリデーション(v.url()など)は、バンドルに含まれません。


    アーキテクチャ: 共有パッケージ

    モノレポ構成で、スキーマを一元管理します。

    flowchart TB
        subgraph packages["📦 packages/shared"]
            S[Valibot Schemas]
            T[Type Definitions]
            S --> T
        end
    
        subgraph api["🔧 apps/api"]
            AV[vValidator]
            AH[Hono Routes]
            AV --> AH
        end
    
        subgraph mobile["📱 apps/mobile"]
            MQ[React Query]
            MF[Forms]
        end
    
        S --> AV
        T --> AH
        T --> MQ
        S --> MF
    
    attain/
    ├── apps/
    │ ├── api/ # Hono + Cloudflare Workers
    │ └── mobile/ # Expo + React Native
    └── packages/
    └── shared/ # Valibot schemas + types

    この構成のおかげで、スキーマを変更すれば API・Mobile の両方で型エラーが即座に検出されます。


    実装: 共有スキーマ

    基本的なスキーマ定義

    packages/shared/src/schema.ts
    import * as v from 'valibot';
    // Enum(picklist)
    export const FitnessLevel = v.picklist([
    'beginner',
    'intermediate',
    'advanced',
    ]);
    // User Schema
    export const UserSchema = v.object({
    id: v.pipe(v.string(), v.uuid()),
    email: v.pipe(v.string(), v.email()),
    name: v.nullable(v.string()),
    heightCm: v.nullable(v.pipe(v.number(), v.minValue(0))),
    weightKg: v.nullable(v.pipe(v.number(), v.minValue(0))),
    fitnessLevel: FitnessLevel,
    createdAt: v.string(),
    updatedAt: v.string(),
    });
    // 型を自動生成(手動定義不要)
    export type User = v.InferOutput<typeof UserSchema>;

    Create/Updateスキーマの分離

    API設計のベストプラクティスとして、Create/Update用のスキーマを分離します。

    // Create: 必須フィールドとデフォルト値
    export const CreateUserSchema = v.object({
    email: v.pipe(v.string(), v.email()),
    name: v.optional(v.string()),
    fitnessLevel: v.optional(FitnessLevel, 'beginner'), // デフォルト値
    targetWorkoutsPerWeek: v.optional(
    v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(7)),
    3, // デフォルト: 週3回
    ),
    });
    // Update: 全フィールドをオプショナルに
    export const UpdateUserSchema = v.partial(CreateUserSchema);
    // 型を自動生成
    export type CreateUser = v.InferOutput<typeof CreateUserSchema>;
    export type UpdateUser = v.InferOutput<typeof UpdateUserSchema>;

    v.partial()既存スキーマをラップするだけで、Update用スキーマが完成します。

    複合型(ネストしたオブジェクト)

    // Workout Set Schema
    export const WorkoutSetSchema = v.object({
    id: v.pipe(v.string(), v.uuid()),
    sessionId: v.pipe(v.string(), v.uuid()),
    exerciseId: v.pipe(v.string(), v.uuid()),
    setNumber: v.pipe(v.number(), v.integer(), v.minValue(1)),
    weightKg: v.pipe(v.number(), v.minValue(0)),
    reps: v.pipe(v.number(), v.integer(), v.minValue(1)),
    // RIR: Reps in Reserve (0=限界, 3=余裕あり)
    rir: v.nullable(
    v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(3)),
    ),
    isWarmup: v.boolean(),
    isFailed: v.boolean(),
    isPr: v.boolean(),
    completedAt: v.string(),
    });
    // JOINした結果用の拡張型
    export type WorkoutSetWithExercise = WorkoutSet & {
    exerciseNameKey: string;
    exerciseEquipment: string;
    exerciseCategory: string;
    };

    実装: API側(Hono)

    @hono/valibot-validatorの導入

    Terminal window
    pnpm add @hono/valibot-validator

    ルートでのバリデーション

    apps/api/src/routes/workouts.ts
    import { CreateWorkoutSetSchema } from '@attain/shared';
    import { vValidator } from '@hono/valibot-validator';
    import { Hono } from 'hono';
    const workouts = new Hono();
    // POST /workouts/:parent/sets
    workouts.post(
    '/:parent/sets',
    vValidator('json', CreateWorkoutSetSchema), // ← バリデーション
    async (c) => {
    const body = c.req.valid('json'); // ← 型が自動推論される
    // body.exerciseId, body.weightKg, body.reps... すべて型安全
    const setId = generateId();
    await c.env.DB.prepare(`INSERT INTO workout_sets (...) VALUES (...)`)
    .bind(
    setId,
    body.exerciseId,
    body.weightKg,
    body.reps,
    // ...
    )
    .run();
    return c.json({ workoutSet: { id: setId, ...body } }, 201);
    },
    );

    vValidatorを使うことで:

    1. リクエストボディを自動バリデーション
    2. バリデーション失敗時は400エラーを自動返却
    3. c.req.valid("json")型安全なアクセス

    エラーレスポンスのカスタマイズ

    デフォルトのエラーメッセージをカスタマイズしたい場合:

    import { vValidator } from '@hono/valibot-validator';
    const customValidator = vValidator('json', CreateUserSchema, (result, c) => {
    if (!result.success) {
    return c.json(
    {
    error: {
    code: 'INVALID_ARGUMENT',
    message: 'Validation failed',
    details: result.issues.map((issue) => ({
    field: issue.path?.map((p) => p.key).join('.'),
    message: issue.message,
    })),
    },
    },
    400,
    );
    }
    });

    実装: フロントエンド側

    React Queryとの統合

    apps/mobile/src/hooks/useUser.ts
    import { useQuery, useMutation } from '@tanstack/react-query';
    import type { User, UpdateUser } from '@attain/shared';
    type UserResponse = {
    user: User; // ← 共有型を使用
    };
    export const useUser = () => {
    return useQuery<UserResponse>({
    queryKey: ['user', 'me'],
    queryFn: async () => {
    const res = await apiFetch('/api/users/me');
    if (!res.ok) throw new Error('Failed to fetch user');
    return res.json() as Promise<UserResponse>;
    },
    });
    };
    export const useUpdateUser = () => {
    return useMutation<UserResponse, Error, UpdateUser>({
    mutationFn: async (data: UpdateUser) => {
    // ← 共有型を使用
    const res = await apiFetch('/api/users/me', {
    method: 'PATCH',
    body: JSON.stringify(data),
    });
    if (!res.ok) throw new Error('Failed to update user');
    return res.json() as Promise<UserResponse>;
    },
    });
    };

    メリット:

    • UpdateUser型の変更が、フロントエンドで即座に型エラーとして検出される
    • APIレスポンスの構造変更も同様に検出可能

    フォームバリデーション

    フロントエンドでも同じスキーマでバリデーション可能:

    import * as v from 'valibot';
    import { CreateBodyMeasurementSchema } from '@attain/shared';
    const handleSubmit = (formData: unknown) => {
    const result = v.safeParse(CreateBodyMeasurementSchema, formData);
    if (!result.success) {
    // バリデーションエラー
    const errors = result.issues.map((issue) => ({
    field: issue.path?.map((p) => p.key).join('.'),
    message: issue.message,
    }));
    setErrors(errors);
    return;
    }
    // result.output は型安全
    mutation.mutate(result.output);
    };

    この設計の実装例をすぐに確認したい方へ: Valibotを使った実践的なフルスタック型共有は、公開中の筋トレ記録アプリ Attain で体験できます。

    👉 Attainをブラウザで試す


    実践パターン: 型の拡張

    JOINした結果の型

    SQLのJOIN結果は、基本スキーマに追加フィールドが含まれます。

    // 基本型
    export type SessionExercise = v.InferOutput<typeof SessionExerciseSchema>;
    // JOINした結果用の拡張型(型の交差で表現)
    export type SessionExerciseWithDetails = SessionExercise & {
    exerciseNameKey: string;
    exerciseEquipment: string;
    exerciseCategory: string;
    exerciseSubcategory: string | null;
    };

    APIレスポンス型

    Google AIP準拠のレスポンス形式:

    // List response (AIP-132)
    type WorkoutsResponse = {
    workoutSessions: WorkoutSession[];
    nextPageToken?: string;
    };
    // Get response (AIP-131)
    type WorkoutResponse = {
    workoutSession: WorkoutSession;
    sessionExercises: SessionExerciseWithDetails[];
    workoutSets: WorkoutSetWithExercise[];
    };

    トレードオフと注意点

    1. Zodエコシステムとの互換性

    Zodには豊富なエコシステム(zod-to-json-schema, react-hook-form連携など)がありますが、Valibotはまだ発展途上です。

    対策: 必要な機能は自前で実装するか、valibot-json-schemaなどの代替を使用

    2. 学習コスト

    Zodのメソッドチェーンに慣れていると、Valibotのパイプ構文は違和感があります。

    // Zod
    const schema = z.string().email().max(255);
    // Valibot
    const schema = v.pipe(v.string(), v.email(), v.maxLength(255));

    ただし、コードベースを読む分には十分に読みやすいです。

    3. 実行時バリデーションの配置

    • API側では必須。外部からの入力は常にバリデーションする
    • フロントエンド側は任意。UXのためのプレバリデーションとして活用する

    フロントエンドでの過剰なバリデーションは、バンドルサイズ増加につながるため注意。


    まとめ: 「型で守る」開発体験

    Valibotを使ったフルスタック型共有は、以下のメリットをもたらします:

    BeforeAfter
    API仕様書を手動で同期スキーマが唯一の真実の源
    型定義を二重管理v.InferOutputで自動生成
    本番でクラッシュ発見ビルド時に型エラーで検出
    巨大なバンドルTree-shakingで最小化

    「型で守られた開発体験」を、ぜひ試してみてください。


    この設計パターンを実装したアプリで検証

    Valibotによる型安全なフルスタック開発を実践した筋トレ記録アプリ Attain では、この記事で解説したアーキテクチャをそのまま実装しています。

    実装のメリットを実感できる機能

    機能体験できること
    ワークアウト記録リアルタイムでセット追加・更新・削除できるUI
    Smart Pre-fill前回のセット情報を自動入力(スキーマ型安全)
    オフライン対応ネットワーク遮断時も同じ操作感
    複数デバイス同期型不整合エラーなしで自動同期

    👉 Attainを試す(PWA対応)


    質問・フィードバックは X (@paveg_) まで!

    X Facebook B! Hatena