0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ZodユーザーがValibotを触りながら違いを理解する

0
Posted at

はじめに

先週、Cloudflare Workers Tech Talks in Osakaに参加してきました。

その中で、ValibotのOSSコントリビューターの方がValibotを紹介されていて、「まずは自分でも触ってみたい」と思い、この記事を書いています。

普段、ValidationにはZodを使っているので、ValibotはZodとどこが違うのか、どのような考え方で設計されているのかを意識しながら学んでいこうと思います!

Valibotとは??

Valibotアイキャッチ

Valibotは、TypeScript向けのモジュール式で型安全なスキーマライブラリです。

フォームの入力値、APIのレスポンス、環境変数など、アプリケーションの外部から来るデータが期待した形式になっているかを実行時に検証できます。

Valibotハイライト

Valibot公式サイトで紹介されている主な特徴(出典:Valibot公式サイト

TypeScriptの型は、コンパイル後のJavaScriptには残りません。そのため、次のようにAPIレスポンスへ型を付けただけでは、実際の値がTodoの構造になっていることは保証されません。

type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

const data = (await response.json()) as Todo;

ここでスキーマを使うと、unknownな値を実行時に検証し、検証に成功した値を型安全に扱えるようになります。

外部データ(unknown)
        ↓
Valibotのスキーマで検証・変換
        ↓
検証済みで型の付いたデータ

Valibotの公式ドキュメントでは、主な構成要素を次の3つに分けています。

Valibotメンタルモデル

Schema・Method・Actionの関係(参考:Valibot Mental model

  • Schemastringobjectなど、値の基本的な型や構造を検証する
  • ActionemailminLengthtrimなど、追加の検証や変換を行う
  • MethodparsesafeParsepipeなど、スキーマを利用・変更する

例えば、文字列であることを検証し、前後の空白を削除してからメールアドレスかを確認するスキーマは、次のように書けます。

import * as v from 'valibot';

const EmailSchema = v.pipe(
  v.string(),
  v.trim(),
  v.email(),
);

pipeの中を左から右へ値が流れ、それぞれのActionが順番に値を検証・変換します。

Valibotの特徴は、一つの大きな関数やクラスに多くの機能を持たせるのではなく、小さく独立した関数を組み合わせてスキーマを作ることです。このモジュール式の設計により、バンドラーが未使用の機能をtree shakingで取り除きやすくなっています。

特に、ブラウザへJavaScriptを配信するフロントエンドや、起動時間が重要になるWorkers・Serverless環境では、この小さなバンドルサイズがメリットになりそうです。

Zodとの違いは??

ValibotとZodは、どちらも次のような用途に使えます。

  • 実行時のデータ検証
  • スキーマからのTypeScript型推論
  • 入力値の変換
  • 詳細なValidationエラーの取得

目的はよく似ていますが、APIの設計に大きな違いがあります。

最初に、主な違いを表で整理します。

比較項目 Valibot Zod
APIの考え方 小さな関数を組み合わせる スキーマのメソッドをチェーンする
文字列の追加検証 v.pipe(v.string(), v.email()) z.string().email()
検証して値を取得 v.parse(Schema, input) Schema.parse(input)
例外を投げない検証 v.safeParse(Schema, input) Schema.safeParse(input)
safeParse成功時 result.output result.data
safeParse失敗時 result.issues result.error.issues
出力型の推論 v.InferOutput<typeof Schema> z.infer<typeof Schema>またはz.output<typeof Schema>
入力型の推論 v.InferInput<typeof Schema> z.input<typeof Schema>
判別可能Union v.variant('type', [...]) z.discriminatedUnion('type', [...])
ブランド型 v.pipe(Schema, v.brand('UserId')) Schema.brand<'UserId'>()
モジュール性 単機能の関数へ分割され、tree shakingを重視 通常版はメソッド中心。軽量なzod/miniもある
向いている場面 ブラウザ、Workers、Serverlessなどバンドルサイズを抑えたい場面 メソッドチェーンの発見しやすさや既存エコシステムを重視する場面

メソッドチェーンと関数の合成

Zodでは、スキーマが持つメソッドをチェーンして制約を追加します。

import * as z from 'zod';

const EmailSchema = z
  .string()
  .trim()
  .email();

Valibotでは、独立した関数をpipeで合成します。

import * as v from 'valibot';

const EmailSchema = v.pipe(
  v.string(),
  v.trim(),
  v.email(),
);

Zodはスキーマオブジェクトを中心に操作し、Valibotは小さな関数を組み合わせてデータの流れを作る、と考えると分かりやすそうです。

parseとsafeParseの呼び出し方

Zodでは、スキーマ自身のメソッドとして呼び出します。

const email = EmailSchema.parse(input);
const result = EmailSchema.safeParse(input);

Valibotでは、スキーマを関数の第1引数に渡します。

const email = v.parse(EmailSchema, input);
const result = v.safeParse(EmailSchema, input);

また、safeParseが成功した場合の値や失敗時の情報にも名前の違いがあります。

成功時 失敗時
Valibot result.output result.issues
Zod result.data result.error.issues

型推論

どちらもスキーマをTypeScript型の唯一の情報源として利用できます。

// Valibot
type User = v.InferOutput<typeof UserSchema>;

// Zod
type User = z.infer<typeof UserSchema>;

変換によって入力と出力の型が異なる場合、ValibotではInferInputInferOutputを使い分けられます。

const PageSchema = v.pipe(
  v.string(),
  v.transform((value) => Number(value)),
  v.integer(),
  v.minValue(1),
);

type PageInput = v.InferInput<typeof PageSchema>;   // string
type PageOutput = v.InferOutput<typeof PageSchema>; // number

バンドルサイズ

Valibotは、各機能が小さく独立しているため、利用していないコードを削除しやすい構造です。

公式の比較ページに掲載されている特定のログインフォーム用スキーマでは、通常版のZodよりもValibotのバンドルが大幅に小さくなる測定結果が示されています。Zod v4にはtree shakingを意識したzod/miniもあるため、実際の比較では使用するAPIやバンドラーなど、同じ条件で計測する必要があります。

機能の有無だけでなく、「必要な機能だけをアプリへ含める」というモジュール性を重視している点が、Valibotの大きな特徴だと感じました。

実際にコードを書いてみる。

ここからは、実際のアプリケーションでValidationが必要になりそうなケースごとに書いてみます。

ケース1:フォーム入力のエラーを画面に表示する

ユーザーが入力するフォームでは、Validationエラーは例外的な障害ではありません。「入力に成功したか」「どの項目に問題があるか」を画面側で分岐して扱いたいため、safeParseが向いています。

import * as v from 'valibot';

export const SignupSchema = v.object({
  email: v.pipe(
    v.string(),
    v.email('有効なメールアドレスを入力してください'),
  ),
  password: v.pipe(
    v.string(),
    v.minLength(8, 'パスワードは8文字以上です'),
    v.regex(/[0-9]/, '数字を1文字以上含めてください'),
  ),
});

export type Signup = v.InferOutput<typeof SignupSchema>;

export function validateSignup(input: unknown) {
  return v.safeParse(SignupSchema, input);
}

const input: unknown = {
  email: 'wrong',
  password: 'short',
};

const result = validateSignup(input);

if (result.success) {
  // result.outputは検証済みの値
  await signup(result.output);
} else {
  // フォームで扱いやすい形へ変換する
  const errors = v.flatten<typeof SignupSchema>(result.issues);
  console.log(errors);
}

safeParseは失敗しても例外を投げず、successfalseの結果を返します。v.flattenを使うと、ネストしたissueをフォーム項目ごとのエラーとして扱いやすくできます。

テストコードと実行結果を見る
import assert from 'node:assert/strict';
import test from 'node:test';
import * as v from 'valibot';
import {
  SignupSchema,
  validateSignup,
} from './01-form-validation.ts';

test('正しいフォーム入力を検証できる', () => {
  const result = validateSignup({
    email: 'learner@example.com',
    password: 'password1',
  });

  assert.equal(result.success, true);
  if (result.success) {
    assert.deepEqual(result.output, {
      email: 'learner@example.com',
      password: 'password1',
    });
  }
});

test('項目ごとのフォームエラーを取得できる', () => {
  const result = validateSignup({ email: 'wrong', password: 'short' });

  assert.equal(result.success, false);
  if (!result.success) {
    const errors = v.flatten<typeof SignupSchema>(result.issues);
    assert.deepEqual(errors.nested?.email, [
      '有効なメールアドレスを入力してください',
    ]);
    assert.deepEqual(errors.nested?.password, [
      'パスワードは8文字以上です',
      '数字を1文字以上含めてください',
    ]);
  }
});
npm run test:01
✔ 正しいフォーム入力を検証できる
✔ 項目ごとのフォームエラーを取得できる

tests 2
pass 2
fail 0

ケース2:APIレスポンスが契約どおりか検証する

fetch(...).json()で取得した値は、TypeScriptの型だけでは実際の中身を保証できません。外部APIとの境界では、レスポンスをunknownとして受け取り、スキーマを通してからアプリケーション内で使います。

import * as v from 'valibot';

export const TodoSchema = v.object({
  id: v.pipe(v.number(), v.integer(), v.minValue(1)),
  title: v.pipe(v.string(), v.minLength(1)),
  completed: v.boolean(),
});

export type Todo = v.InferOutput<typeof TodoSchema>;

export function decodeTodo(data: unknown): Todo {
  return v.parse(TodoSchema, data);
}

const response: unknown = {
  id: 1,
  title: 'Valibotを試す',
  completed: false,
  extra: '出力には含まれない',
};

const todo = decodeTodo(response);
console.log(todo);
// { id: 1, title: 'Valibotを試す', completed: false }

ここでは、APIが契約違反のデータを返した場合、そのまま処理を継続するのは危険なのでparseを使っています。検証に失敗するとValiErrorが投げられ、呼び出し側のエラーハンドリングへ処理を移せます。

つまり、次のように使い分けられます。

  • 入力エラーを通常の分岐として扱う:safeParse
  • 不正なデータなら処理を中断する:parse
テストコードと実行結果を見る
import assert from 'node:assert/strict';
import test from 'node:test';
import * as v from 'valibot';
import { decodeTodo } from './02-api-response.ts';

test('APIレスポンスを検証して未知のキーを除去する', () => {
  const todo = decodeTodo({
    id: 1,
    title: 'Valibotを試す',
    completed: false,
    extra: '出力には含まれない',
  });

  assert.deepEqual(todo, {
    id: 1,
    title: 'Valibotを試す',
    completed: false,
  });
});

test('契約違反のAPIレスポンスではValiErrorを投げる', () => {
  assert.throws(
    () => decodeTodo({ id: 1, title: 'Todo', completed: 'false' }),
    (error: unknown) => v.isValiError(error),
  );
});
npm run test:02
✔ APIレスポンスを検証して未知のキーを除去する
✔ 契約違反のAPIレスポンスではValiErrorを投げる

tests 2
pass 2
fail 0

ケース3:文字列の入力をアプリ用の型へ変換する

URLのクエリパラメータやフォームの値は、数値であっても文字列として渡されることがあります。Valibotでは、検証と変換を同じpipeの中に順番どおり記述できます。

import * as v from 'valibot';

export const SearchParamsSchema = v.object({
  query: v.pipe(
    v.string(),
    v.trim(),
    v.minLength(1, '検索キーワードを入力してください'),
  ),
  page: v.optional(
    v.pipe(
      v.string(),
      v.transform((value) => Number(value)),
      v.integer('ページ番号は整数で指定してください'),
      v.minValue(1, 'ページ番号は1以上です'),
    ),
    '1',
  ),
});

export type SearchParamsInput = v.InferInput<typeof SearchParamsSchema>;
export type SearchParams = v.InferOutput<typeof SearchParamsSchema>;

export function parseSearchParams(input: unknown): SearchParams {
  return v.parse(SearchParamsSchema, input);
}

const input: SearchParamsInput = {
  query: '  valibot  ',
  page: '3',
};

const output: SearchParams = parseSearchParams(input);

console.log(output);
// { query: 'valibot', page: 3 }

このスキーマでは、入力と出力の型が異なります。

入力:  { query: string; page?: string }
                         ↓
出力:  { query: string; page: number }

page: 'zero'を渡すと、Number('zero')NaNになり、その後のintegerで検証に失敗します。変換後の値にも制約をかけられるところがpipeの便利な点です。

テストコードと実行結果を見る
import assert from 'node:assert/strict';
import test from 'node:test';
import * as v from 'valibot';
import { parseSearchParams } from './03-search-params.ts';

test('空白を除去し、pageをnumberへ変換する', () => {
  assert.deepEqual(
    parseSearchParams({ query: '  valibot  ', page: '3' }),
    { query: 'valibot', page: 3 },
  );
});

test('pageを省略するとnumberの1を出力する', () => {
  assert.deepEqual(parseSearchParams({ query: 'valibot' }), {
    query: 'valibot',
    page: 1,
  });
});

test('数値に変換できないpageを拒否する', () => {
  assert.throws(
    () => parseSearchParams({ query: 'valibot', page: 'zero' }),
    (error: unknown) => v.isValiError(error),
  );
});
npm run test:03
✔ 空白を除去し、pageをnumberへ変換する
✔ pageを省略するとnumberの1を出力する
✔ 数値に変換できないpageを拒否する

tests 3
pass 3
fail 0

ケース4:環境変数やWorkersのBindingsを起動時に検証する

環境変数は、設定漏れやタイプミスが実行時エラーにつながりやすい場所です。アプリケーションの入口で一度検証しておくと、それ以降のコードでは検証済みの設定だけを扱えます。

import * as v from 'valibot';

export const EnvSchema = v.object({
  API_BASE_URL: v.pipe(
    v.string('API_BASE_URLが設定されていません'),
    v.url('API_BASE_URLの形式が正しくありません'),
  ),
  LOG_LEVEL: v.optional(
    v.picklist(['debug', 'info', 'warn', 'error']),
    'info',
  ),
});

export type AppEnv = v.InferOutput<typeof EnvSchema>;

export function decodeEnv(bindings: unknown): AppEnv {
  return v.parse(EnvSchema, bindings);
}

export default {
  async fetch(_request: Request, env: unknown): Promise<Response> {
    const appEnv = decodeEnv(env);

    return Response.json({
      apiBaseUrl: appEnv.API_BASE_URL,
      logLevel: appEnv.LOG_LEVEL,
    });
  },
};

設定が不正な状態で処理を続けないよう、ここでもparseを使っています。本番環境だけで発生する設定ミスを、アプリケーションの境界で検出できます。

テストコードと実行結果を見る
import assert from 'node:assert/strict';
import test from 'node:test';
import * as v from 'valibot';
import { decodeEnv } from './04-workers-env.ts';

test('Workers Bindingsを検証する', () => {
  assert.deepEqual(
    decodeEnv({
      API_BASE_URL: 'https://api.example.com',
      LOG_LEVEL: 'warn',
    }),
    {
      API_BASE_URL: 'https://api.example.com',
      LOG_LEVEL: 'warn',
    },
  );
});

test('LOG_LEVELを省略するとinfoを使う', () => {
  assert.deepEqual(decodeEnv({ API_BASE_URL: 'https://api.example.com' }), {
    API_BASE_URL: 'https://api.example.com',
    LOG_LEVEL: 'info',
  });
});

test('不正なURLを拒否する', () => {
  assert.throws(
    () => decodeEnv({ API_BASE_URL: 'not-a-url' }),
    (error: unknown) => v.isValiError(error),
  );
});
npm run test:04
✔ Workers Bindingsを検証する
✔ LOG_LEVELを省略するとinfoを使う
✔ 不正なURLを拒否する

tests 3
pass 3
fail 0

ケース5:DDDの値をブランド型で区別する

DDDでは、ユーザーIDと注文IDのように、実体はどちらもstringでもドメイン上の意味が異なる値を区別したいことがあります。

type UserId = string;
type OrderId = string;

function findUser(id: UserId) {
  // ユーザーを検索する
}

const orderId: OrderId = '550e8400-e29b-41d4-a716-446655440000';

// どちらもstringなので、コンパイルエラーにならない
findUser(orderId);

Valibotではv.brand()を使い、検証済みの出力へブランドを付けられます。

import * as v from 'valibot';

export const UserIdSchema = v.pipe(
  v.string(),
  v.uuid('UserIdはUUID形式で指定してください'),
  v.brand('UserId'),
);

export const OrderIdSchema = v.pipe(
  v.string(),
  v.uuid('OrderIdはUUID形式で指定してください'),
  v.brand('OrderId'),
);

export type UserId = v.InferOutput<typeof UserIdSchema>;
export type OrderId = v.InferOutput<typeof OrderIdSchema>;

export function createUserId(input: unknown): UserId {
  return v.parse(UserIdSchema, input);
}

export function createOrderId(input: unknown): OrderId {
  return v.parse(OrderIdSchema, input);
}

export function findUser(id: UserId): string {
  return `User ${id} を検索します`;
}

const userId = createUserId(
  '550e8400-e29b-41d4-a716-446655440000',
);

const orderId = createOrderId(
  '550e8400-e29b-41d4-a716-446655440001',
);

findUser(userId);  // OK
findUser(orderId); // TypeScriptのコンパイルエラー

メールアドレスのように、表記を正規化してからドメイン型として扱うこともできます。

export const EmailAddressSchema = v.pipe(
  v.string(),
  v.trim(),
  v.email('メールアドレスの形式が正しくありません'),
  v.toLowerCase(),
  v.brand('EmailAddress'),
);

export type EmailAddress = v.InferOutput<typeof EmailAddressSchema>;

export function createEmailAddress(input: unknown): EmailAddress {
  return v.parse(EmailAddressSchema, input);
}

const email = createEmailAddress('  USER@example.com  ');
// 値は'user@example.com'、型はEmailAddress

brand自体はUUIDやメールアドレスを検証するものではありません。前段の検証・変換を通過した値の出力型にブランドを付けるActionなので、基本的にはpipeの最後に置きます。

また、ブランドはTypeScript上の区別であり、実行時に専用のラッパーオブジェクトへ変わるわけではありません。上のUserIdEmailAddressの実行時の値は、通常の文字列のままです。そのため、次の点には注意が必要です。

  • APIやDBから読み直した値は、境界で再度スキーマを通す
  • ブランド型の値を型アサーションで直接作らない
  • 値に振る舞いを持たせたい場合は、クラスやValue Object用のオブジェクトも検討する

ブランド型はValue Objectの完全な代替ではありませんが、「検証済みの値だけをドメイン層へ渡す」「同じプリミティブ型のIDを取り違えない」という用途には使いやすそうです。

テストコードと実行結果を見る
import assert from 'node:assert/strict';
import test from 'node:test';
import * as v from 'valibot';
import {
  createEmailAddress,
  createUserId,
  findUser,
  type OrderId,
  type UserId,
} from './05-branded-domain-types.ts';

test('検証済みのUserIdをドメイン関数へ渡せる', () => {
  const userId = createUserId('550e8400-e29b-41d4-a716-446655440000');
  assert.equal(
    findUser(userId),
    'User 550e8400-e29b-41d4-a716-446655440000 を検索します',
  );
});

test('ブランド型でも実行時の値はstringのまま', () => {
  const userId = createUserId('550e8400-e29b-41d4-a716-446655440000');
  assert.equal(typeof userId, 'string');
});

test('メールアドレスを正規化してブランドを付ける', () => {
  assert.equal(
    createEmailAddress('  USER@example.com  '),
    'user@example.com',
  );
});

test('不正なUUIDを拒否する', () => {
  assert.throws(
    () => createUserId('not-a-uuid'),
    (error: unknown) => v.isValiError(error),
  );
});

function typeCheckBrands(userId: UserId, orderId: OrderId): void {
  findUser(userId);
  // @ts-expect-error OrderIdをUserIdとして扱うことはできない
  findUser(orderId);
}

void typeCheckBrands;
npm run test:05
npm run check
✔ 検証済みのUserIdをドメイン関数へ渡せる
✔ ブランド型でも実行時の値はstringのまま
✔ メールアドレスを正規化してブランドを付ける
✔ 不正なUUIDを拒否する

tests 4
pass 4
fail 0

> tsc --noEmit

UserIdOrderIdの取り違えは実行時テストではなく、npm run checkによるTypeScriptの型チェックで確認しています。

ケース6:複数の種類を持つデータを安全に分岐する

イベントや通知のように、種類によって必要なプロパティが異なるデータにはvariantを使えます。ZodのdiscriminatedUnionに相当する機能です。

import * as v from 'valibot';

export const NotificationSchema = v.variant('type', [
  v.object({
    type: v.literal('email'),
    address: v.pipe(v.string(), v.email()),
  }),
  v.object({
    type: v.literal('sms'),
    phone: v.pipe(
      v.string(),
      v.regex(/^\+?[0-9]{10,15}$/),
    ),
  }),
]);

export type Notification = v.InferOutput<typeof NotificationSchema>;

export function decodeNotification(input: unknown): Notification {
  return v.parse(NotificationSchema, input);
}

export function describeNotification(notification: Notification): string {
  if (notification.type === 'email') {
    // TypeScriptはemailのデータだと絞り込める
    return `メール送信先: ${notification.address}`;
  }
  // こちらではsmsのデータだと絞り込める
  return `SMS送信先: ${notification.phone}`;
}

const notification = decodeNotification({
  type: 'email',
  address: 'team@example.com',
});

console.log(describeNotification(notification));
// メール送信先: team@example.com

type: 'push'のような定義されていない種類や、emailなのにaddressがないデータは検証に失敗します。外部から受け取ったイベントの種類とデータ構造を、実行時とTypeScriptの両方で揃えられます。

テストコードと実行結果を見る
import assert from 'node:assert/strict';
import test from 'node:test';
import * as v from 'valibot';
import {
  decodeNotification,
  describeNotification,
} from './06-variant.ts';

test('email通知を検証して処理できる', () => {
  const notification = decodeNotification({
    type: 'email',
    address: 'team@example.com',
  });
  assert.equal(
    describeNotification(notification),
    'メール送信先: team@example.com',
  );
});

test('sms通知を検証して処理できる', () => {
  const notification = decodeNotification({
    type: 'sms',
    phone: '+819012345678',
  });
  assert.equal(describeNotification(notification), 'SMS送信先: +819012345678');
});

test('未定義の通知タイプを拒否する', () => {
  assert.throws(
    () => decodeNotification({ type: 'push', token: 'device-token' }),
    (error: unknown) => v.isValiError(error),
  );
});
npm run test:06
✔ email通知を検証して処理できる
✔ sms通知を検証して処理できる
✔ 未定義の通知タイプを拒否する

tests 3
pass 3
fail 0

今回のケースを整理すると、次のようになります。

ユースケース 主に使う機能 ポイント
フォーム入力 safeParseflatten 失敗を画面表示用のデータとして扱う
APIレスポンス parseobject unknownを検証してから利用する
クエリパラメータ pipetransform 検証しながらアプリ用の型へ変換する
環境変数・Bindings parseoptional 不正な設定では処理を開始しない
DDDの値 brand 検証済みの値にドメイン上の意味を付ける
種類の異なるデータ variant 判別可能Unionとして安全に分岐する

実際に書いてみると、Zodのメソッドチェーンとは見た目が違うものの、基本的な考え方はよく似ています。一方で、pipeによって「基本型の検証 → 追加の検証 → 変換 → ブランド付け」というデータの流れが明示されるところに、Valibotらしさを感じました。

まとめ

今回触ってみて理解したValibotの特徴は、次のとおりです。

  • TypeScriptでは保証できない実行時の値を検証できる
  • スキーマからTypeScriptの型を推論できる
  • 小さく独立した関数をpipeで組み合わせる
  • brandで検証済みのドメイン型を表現できる
  • モジュール式の設計によってtree shakingしやすい
  • Zodと目的はよく似ているが、APIの組み立て方が異なる

まだ基本的な機能を触った段階ですが、Valibotは単に「Zodと同じことを別の書き方で行うライブラリ」ではなく、モジュール性と小さなバンドルサイズを重視して設計されたスキーマライブラリだと分かりました。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?