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でフォーム作る人にオススメな構成だと言えるでしょう。

続きを読む

2020年に買ったもの

2020年に買って印象深かったものの紹介

音楽

Spotify

いきなり買い物というか月額課金サービスだけど、間違いなく今年一番生活を変えたサービス。 一定金額で無限に音楽聞けるだけのサービスだと思ってたけど、次々と「お前こういうの好きだろ」と無限に勧めてくれる。 仕事中は半分くらい聞いてるので、月に100時間くらい聞いてるんじゃなかろうか。

CD買って挙動がアホなiTunesに取り込んでUSBケーブル繋ぐも一定確率でiPhoneを認識せず何かしらのサービスを再起動したりUSBポートを変えてようやく認識したとiPhoneに転送して…といった具合のをずっとやっており飛躍が激しすぎる。 今なら突然現代に連れてこられた江戸っ子の気持ちが分かる。

いつでもおすすめ楽曲のリンクお待ちしています。 なお、最近の自分のブームはCYNHN, NONA REEVESあたりです。

AfterShokz AEROPEX

耳をふさがない骨伝導イヤホン。 ジョギング・自転車の時につけていても、周りの音がはっきり聞こえる。 でも音楽も聞こえる不思議なデバイス。

会社の人が買ったということで2,3分貸してもらい感動してすぐ買った。 雑音が非常に大きい電車(特に地下鉄)には向かない。

https://aftershokz.jp/products/aeropex

家電系

31.5インチ 4Kディスプレイ

LGの32UK550-Bを4万5千円程度で購入。

去年の暮あたりに使った27インチWQHDディスプレイの事が忘れられず、在宅勤務が多くなってきた時期に購入。 今までCRTかIPS液晶しか購入したことが無かったが、少なくVAだと感じさせない視野角。 どうせバックライトだめになって色変わるんだし、安物IPSを買うくらいならVAである程度の頻度で買い換えるほうが良いかもしれない。

https://www.amazon.co.jp/gp/product/B07HRZ7LFB

中華スマートプラグ

飯の前にコタツが勝手にONするので非常にあったかライフ。 寝る時間に勝手にOFFも設定しているので消したか心配になって見に行くやつが無くなった。 (※火災などが発生しづらい家電で利用)

リンクは一応張っているものの、中華のよく出回っているスマートプラグなら何でも良い。 というのも、Tuyaというメーカーが大量にOEMで出しており、「TuyaSmart」「Smart Life」「Woox home」どのアプリでも登録できるし機能差は無い。 アプリのスクリーンショットを見て似てそうだったら恐らくTuyaのOEMを引ける。

https://www.amazon.co.jp/gp/product/B07XBWR6GF

Anker PowerWave

充電できるスマホ台。

PC触っている時にiPhoneを置くようにしていると、バッテリーを気にする必要がなくて良い。 あっ電池少ないというストレスが減った。

https://www.amazon.co.jp/gp/product/B07RHT4F8M

Xiaomi Mi Smart Band 4

3千円台で購入できるスマートバンド。 風呂にも海にも付けていったがピンピンしてるタフなやつ。

iPhoneの通知を受信・ジョギングやサイクリングのログを取得など出来るが、バイブでゆったりと起こしてくれるアラームが一番気に入っているかも。

今は次期モデル5が出ているので注意。

https://www.mi.com/jp/mi-smart-band-4/

生活

ラバーゼ 揚げ鍋

ずっと買おうかと悩んでいたやつ。 揚げ物との心理的距離が縮まった。

業務スーパーで冷凍の揚げ物よく買ってくるんだけど、結露がすごくてバッチバチ跳ねてキッチン周りが油まみれになるという地獄を毎回やっていた。 この揚鍋にはフタがついていて跳ねた油を受け止めてくれる構造になっている。 出来上がったら網でザッとすくう。 掃除もやりやすいので、揚げ物すきな人にはオススメしたい。

https://www.amazon.co.jp/gp/product/B0847QR768

ブリタ浄水器 2.0L

ポット型の浄水器。

大阪市は水が美味しいはずなんだけど、集合住宅なので味が終わっている。 カルキ臭さ・他の訳のわからん生臭さ等が全て無くなる魔法のポット。

以前はペットボトルの水を大量に買っていたが、そこそこ高価だし運ぶのも大変だし切り替えてみた。 味の差はありつつも飲めない味ではないので半年ほど使っている。

https://www.amazon.co.jp/gp/product/B074P7KWZ9


YOUのオススメ商品も教えてくれよな!

続きを読む