TypeScriptでのバリデーションにsuperstructが最高

TypeScript に関わらずバリデーションを書く場面というのは非常に多いと思います。例えば、フォーム・API に POST された HTTP Body の検証等、場面は変わってもやることはあまり変わらないと思います。愚直に if 文を書く場面も多いかと思うのですが、TypeScript のライブラリsuperstructを利用すると記述をシンプルに行なえます。

サンプルリポジトリ: https://github.com/kamijin-fanta/react-form-schemavalidation-example

↑ 雑なスライドです。この記事も同様の内容を記述しています。

Data Structure Validation

  • フィールド型・データの範囲などを定義・検証する仕組み
  • 人間が検証コードを書く必要が無くなって良い
  • 仕様例
    • JSON Schema
    • Swagger
  • 実装例: go, python 等様々な言語にてライブラリが存在

JSON Schema で、2 ~ 3 文字の string というルールを書いた例

{
  "type": "string",
  "minLength": 2,
  "maxLength": 3
}

superstruct

色々仕様・実装が有るわけですが、TypeScript でのみ利用するのであれば superstruct がかなり使いやすいかなと感じます。

まずは簡単な例から。 assert 関数はルールに一致しなければ例外を吐きます。 TypeScript のassertion functionとして利用が可能なので、言語との親和性も高いです。

it("string & assert", () => {
  const strSchema = string(); // 文字列というスキーマを作成

  {
    const input: any = "1234";
    assert(input, strSchema);
    // ここまで到達すればstringであることが保証される
    expect(typeof input).toEqual("string");
  }

  expect(() => {
    const input: any = 1234;
    assert(input, strSchema);
  }).toThrow("Expected a string, but received: 1234");
});

例外を投げてほしくない場合、以下のようにも検証が可能です。 assert 関数の代わりに validate 関数を利用しています。 タプルでエラーと結果が返ります。

it("number & validation", () => {
  const numSchema = number();

  {
    const input: any = 1234;
    const [error, result] = validate(input, numSchema); // numSchema.validate(input)とも書ける
    expect(error).toBeUndefined();
    expect(result).toEqual(1234);
  }

  {
    const input: any = "1234";
    const [error, result] = validate(input, numSchema);
    expect(error?.message).toEqual('Expected a number, but received: "1234"');
    expect(result).toBeUndefined();
  }
});

次はオブジェクトの構造について検証します。 数値のid、文字列のname、文字列を含む配列のtagsを定義しています。 ここでは検証にassertvalidateの代わりにisを利用しているので、型のガードが行われます。

it("object & is", () => {
  const userSchema = object({
    id: number(),
    name: string(),
    tags: array(string()),
  });

  {
    const input: any = { id: 123, name: "mike", tags: ["hoge", "fuga"] };
    expect(is(input, userSchema)).toEqual(true);
    if (is(input, userSchema)) {
      // inputがuserSchemaを満たす場合にのみifに入るので、inputはanyでなくなる
      // input.id, input.name...
    }
  }

  {
    const input: any = { name: "mike" };
    expect(is(input, userSchema)).toEqual(false);
  }
});

今まではデータ型について注目していましたが、内容についてバリデーションを書きます。 name フィールドは長さが 1~20 の文字列であることが必要です。 また、Infer型を用いてuserSchemaからuserTypeの導出も行ってみました。 size以外にもいくつかの制約条件がデフォルトで定義されており、自分で定義することも可能です。

it("refinements", () => {
  const userSchema = object({
    id: number(),
    name: size(string(), 1, 20),
  });
  type userType = Infer<typeof userSchema>;

  {
    const input: userType = { id: 123, name: "" };
    const [error] = validate(input, userSchema);
    expect(error?.message).toEqual(
      "At path: name -- Expected a string with a length between `1` and `20` but received one with a length of `0`"
    );
  }
});

フィールド内容によってその値を書き換える機能も提供されており、例えばからの場合にデフォルト値を設定することが可能です。 validate関数のオプションに{ coerce: true }を指定することで利用が可能です。 ここではデフォルト値として、ID の値を実行するたびに増加する整数を設定しています。

it("coercions", () => {
  let index = 0;
  const userSchema = object({
    id: defaulted(number(), () => index++),
    name: trimmed(size(string(), 1, 20)),
  });

  {
    const input: any = { name: " mike " };

    // 1回目の実行
    const [, res1] = validate(input, userSchema, { coerce: true });
    expect(res1).toEqual({
      id: 0,
      name: "mike",
    });

    // 2回目の実行
    const [, res2] = validate(input, userSchema, { coerce: true });
    expect(res2).toEqual({
      id: 1,
      name: "mike",
    });
  }
});

今までは新たにスキーマから作成していましたが、既存の型に対してのスキーマを作成することも可能です。 Describe<T>型を用いることで、スキーマと既存型が一致していることを検証できます。 残念ながら自分で内容は埋めなければならないのですが、抜け漏れを防ぐことが出来ます。

it("use exist type", () => {
  type User = {
    id: number;
    name: string;
  };

  const userSchema: Describe<User> = object({
    id: number(),
    name: string(),
  });

  const input: any = { id: 123, name: "mike" };
  const [, res] = validate(input, userSchema);
  expect(res).toEqual({
    id: 123,
    name: "mike",
  });
});

react-hook-form と組み合わせる

react-hook-form はパフォーマンスが良く使いやすい React のフォームライブラリです。 バリデーション・繰り返しフィールド等の機能が一通り揃っていて実用的です。

通常 react-hook-form では次のようにフォームを記述します。 バリデーションのルールは register の中に記述されています。

export function SimpleForm(): React.ReactElement {
  const { register, handleSubmit, errors } = useForm();
  const onSubmit = (data: any) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div className="group">
        <label>
          name
          <input
            name="name"
            ref={register({ required: true, minLength: 1, maxLength: 20 })}
          />
          {errors.name && "name is required"}
        </label>
      </div>
      <div className="group">
        <label>
          age
          <input
            name="age"
            type="number"
            ref={register({ required: true, valueAsNumber: true, min: 20 })}
          />
          {errors.age && "age must be upper 20"}
        </label>
      </div>
      <input type="submit" />
    </form>
  );
}

こちらの書き方でも良いのですが、エラー文章を自分で書く必要があり、errors onSubmitが型安全とはなりません。 (もちろん自分で型注釈すれば改善できます) こういった問題を superstruct を組み合わせることで改善できます。

superstruct を利用した例は以下のようになります。

const parsableInt = coerce(number(), string(), (value) => parseInt(value));

const userSchema = object({
  name: size(string(), 1, 20),
  age: min(parsableInt, 20),
});
type userType = Infer<typeof userSchema>;

export function SchemaValidationForm(): React.ReactElement {
  const { register, handleSubmit, errors } = useForm({
    resolver: superstructResolver(userSchema, { coerce: true }),
  });
  const onSubmit = (data: userType) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div className="group">
        <label>
          name
          <input name="name" ref={register()} />
          {errors.name?.message}
        </label>
      </div>
      <div className="group">
        <label>
          age
          <input name="age" type="number" ref={register()} />
          {errors.age?.message}
        </label>
      </div>
      <input type="submit" />
    </form>
  );
}

register 内のルール記述が不要となり、エラーメッセージは superstruct が自動生成します。 errors等もスキーマから自動的に型の推測が行われるので、typo 等の心配もなくなります。

react-hook-form+superstruct は react でフォーム作る人にオススメな構成だと言えるでしょう。