Protovalidateがv1.0に達しました

Protobuf

2025-09-23

#はじめに

Protovalidateがv1に達しました。

Protovalidate is now v1.0
After two years of development, we're proud to announce that Protovalidate has reached v1.0. Protovalidate is the semantic validation library for Protobuf. Protobuf gives you the structure of your data, but Protovalidate ensures the quality of your data. Without semantic validation, you're stuck writing the same validation logic over and over again across every service that consumes your messages. With Protovalidate, you define your validation rules once, directly on your schemas, and they're enforced everywhere.
Protovalidate is now v1.0 favicon buf.build
Protovalidate is now v1.0

これまで待たれていたTypeScriptの実装もv1となり、ようやくフロントエンドからバックエンドの検証ルールも一つの検証ルールで記述することができると良いなと思いますが、この記事では検証を行い、少し手間がかかりました。

今回は、protovalidate v1をご紹介したいと思います。

#protovalidateとは

Protovalidate is the semantic validation library for Protobuf. Protobuf gives you the structure of your data, but Protovalidate ensures the quality of your data. Without semantic validation, you're stuck writing the same validation logic over and over again across every service that consumes your messages. With Protovalidate, you define your validation rules once, directly on your schemas, and they're enforced everywhere.

これまでも、OpenAPI, GraphQL, またはConnectやgRPCでAPIの定義をして、言語をこえてAPIの実装が行われていました。これは特に、Micro Service Architectureで異なる言語でそれぞれのサービスを実装するときに重要な要素です。
しかし、検証ルールは個別の言語で実装する必要がありました。
たとえばフロントエンドであれば、Zod, valibotなどスキーマ検証ライブラリを利用して実装していました。バックエンドをGoで書いていたら、同じスキーマ検証ルールをGoのvalidator, ozzo-validationなど別のライブラリを使って実装していました。
しかし、protovalidateを使うと、これらの重複していた検証ルールを言語をこえて利用できるようになります。

protovalidateは、Go、Java、Python、C++、TypeScriptをサポートしています。
個人的には、必要十分なサポートです。

#v1に達したことの意義

We take stability seriously here at Buf: v1.0 is a commitment that Protovalidate will not break you. Not only are we committing to project stability moving forward, but we’re also confident that it is ready for all of your production workloads.

v1に達したことを機に、仕様が安定することが期待されます。積極的に商用サービスに採用することができそうです。
また、v1以前ではTypeScriptは、まだ実装中だったこともあり、フロントエンドの検証ライブラリはzodなどに頼っていましたが、protovalidateに一本化していけると良いと思いました。

#実際にフロントエンドでもprotovalidateを利用できるか

結論、フロントエンドでprotovalidateを利用は、出来ます。しかし、フロントエンドのFormライブラリなどに統合するのは少し手間がかかりました。

フロントエンドでは、検証ライブラリの標準化として、standard-schemaがあります。

Standard Schema is a common interface designed to be implemented by JavaScript and TypeScript schema libraries.

Standard Schema
A common interface for TypeScript validation libraries
Standard Schema favicon standardschema.dev
Standard Schema

実装しているライブラリの一覧にprotovalidate-esがあります。
一方、standard-schemaを利用する側では、React Hook Formはすでにサポート済みだったので、「resolverを繋げるだけで容易に実装できるのでは?」と期待しました。

#React Hook Form

以下のように実装して動作確認してみました。
protoは、protovalidate.comのトップにあるUserメッセージを利用しました。これを適当にprotovalidate-esで利用できるようコード生成します。

syntax = "proto3";

package acme.user.v1;

import "buf/validate/validate.proto";

message User {
  string id = 1 [(buf.validate.field).string.uuid = true];
  uint32 age = 2 [(buf.validate.field).uint32.lte = 150]; // We can only hope.
  string email = 3 [(buf.validate.field).string.email = true];
  string first_name = 4 [(buf.validate.field).string.max_len = 64];
  string last_name = 5 [(buf.validate.field).string.max_len = 64];

  option (buf.validate.message).cel = {
    id: "first_name_requires_last_name"
    message: "last_name must be present if first_name is present"
    expression: "!has(this.first_name) || has(this.last_name)"
  };
}

Formの実装は下記のようにしました。

import { useMemo } from "react";
import { useForm } from "react-hook-form";
import { UserSchema, type User } from "./gen/acme/user/v1/user_pb"
import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
import { createStandardSchema } from '@bufbuild/protovalidate'


export function Form() {
  const resolver = useMemo(() => standardSchemaResolver(createStandardSchema(UserSchema)), [])
  const { register, handleSubmit, formState: { errors } } = useForm<User>({
    mode: 'all',
    resolver
  });
  const onSubmit = (data: User) => {
    console.log(data, errors);
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} style={{ display: 'flex', padding: '0.5rem', textAlign: 'left', flexDirection: 'column', gap: '1rem', width: '300px' }}>

      <div>
        <label htmlFor="id" style={{ display: 'block' }}>ID</label>
        <input id="id" {...register("id")} style={{ width: '100%' }} />
        {errors.id && <div style={{ color: 'red', }}>{errors.id?.message}</div>}
      </div>
      <div>
        <label htmlFor="age" style={{ display: 'block' }}>Age</label>
        <input id="age" {...register("age")} style={{ width: '100%' }} />
        {errors.age && <div style={{ color: 'red', }}>{errors.age?.message}</div>}
      </div>

      <div>
        <label htmlFor="email" style={{ display: 'block' }}>Email</label>
        <input id="email" type="email" autoComplete="false" {...register("email")} style={{ width: '100%' }} />
        {errors.email && <div style={{ color: 'red', }}>{errors.email?.message}</div>}
      </div>

      <div>
        <label htmlFor="firstName" style={{ display: 'block' }}>First Name</label>
        <input id="firstName" {...register("firstName")} style={{ width: '100%' }} />
        {errors.firstName && <div style={{ color: 'red', }}>{errors.firstName?.message}</div>}
      </div>

      <div>
        <label htmlFor="lastName" style={{ display: 'block' }}>Last Name</label>
        <input id="lastName" {...register("lastName")} style={{ width: '100%' }} />
        {errors.lastName && <div style={{ color: 'red', }}>{errors.lastName?.message}</div>}
      </div>

      <button type="submit" style={{ backgroundColor: '#4CAF50', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
        Submit
      </button>
    </form>
  );
}

これだとうまく動きません。まず、protovalidateでは、@bufbuild/protobufcreate関数でメッセージを生成する必要があります。このメッセージに対して検証を行う必要が有ります。また、戻り値のerrorsには、snake_caseでフィールド名が入っているので、typescript型のフィールド名であるcamelCaseに変換する必要があります。
統合する上で気づいた部分はこの2点ですが、他にもあるかもしれません。

カスタムリゾルバーを実装してみる。

import { useCallback, useMemo } from "react";
import { createStandardSchema } from '@bufbuild/protovalidate'
import { create, type DescMessage } from '@bufbuild/protobuf';
import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
import { toCamelCaseTopLevelKeys } from "./convertCase";

export function useStandardSchemaResolver<T extends DescMessage>(schema: T) {
    const stdSchema = useMemo(() => createStandardSchema(schema), [schema])
    const stdResolver = useMemo(() => standardSchemaResolver(stdSchema), [stdSchema])
    return useCallback(async (data: any) => {
        const result = await stdResolver(create(schema, data), undefined, {})
        result.errors = toCamelCaseTopLevelKeys(result.errors)
        console.log(result)
        return result

    }, [schema, stdResolver])
}

toCamelCaseTopLevelKeysは、objectのトップレベルのキーをcamelCaseに変換するユーティリティ関数です。

下記のようにカスタムリゾルバーを利用するよう変更してみます。

export function Form() {
-  const resolver = useMemo(() => standardSchemaResolver(createStandardSchema(UserSchema)), [])
+  const resolver = useStandardSchemaResolver(UserSchema)
  const { register, handleSubmit, formState: { errors } } = useForm<User>({
    mode: 'all',
    resolver
  });
  const onSubmit = (data: User) => {
    console.log(data, errors);
  }
  ...省略
}

これで、React Hook Formと統合できました。色んなパターンを検証できているわけではないので、例えば、ObjectがNestedになっている場合などは、うまく動かないかもしれません。

form

また、フィールドに紐付かないようなRootレベルのエラーは捕捉できませんした。例だと、last_name must be present if first_name is presentのようなエラーです。こうしたものは、サーバーサイドで捕捉するようにしたほうが良いかもしれません。

#まとめ

protovalidateがv1に達したことで、仕様が安定し、商用サービスでの利用も進むことが期待されます。
フロントエンドでも利用できることは確認できましたが、Formライブラリなどに統合するには、少し手間がかかりました。今後、より簡単に統合できるようになると良いなと思います。