見出し画像

スキーマバリデーションライブラリZod ~君がくれたもの~

こんにちは、みみぞうです。
ナビタイムジャパンで『システムや開発環境、チームの改善』を担当しています。

最近、社内 3~4つのプロダクトにスキーマバリデーションライブラリを導入しましたところ、開発速度やプロダクト品質の大幅な向上につながりました。そこで、今回はスキーマバリデーションライブラリの良さについて、JavaScript実行環境で動作する Zod というライブラリを具体例にご紹介します。

※ Zodのほかにも Yupio-ts といった選択肢もありますが、本稿ではZodのみにフォーカスします

本稿は以下のような方をターゲットにしています。

  • TypeScriptでWeb開発をしており

    • スキーマバリデーションライブラリをご存じでない方

    • スキーマバリデーションライブラリのメリットが分からない方

    • REST APIとの結合でいつも開発がスムーズにいかないとお困りの方

一部Vue.jsのソースコードを例として出しますが、Vue.jsをご存じでなくても雰囲気で読めるようにしています。

🤫プロローグ ~沈黙の不具合~

具体的なイメージを持っていただくため、本稿の最初と最後に物語を掲載しました。主人公である Fさん とご自身を重ね合わせて読み進めていただければと思います。

※ 以降の物語はフィクションであり実在の人物や団体などとは関係ありません。

~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆

R社に所属するエンジニアFは、『スポットランキングシステム』のリニューアル開発を担当していた。このシステムはREST APIからスポットのランキング情報を受け取り、上位3スポットをWebサイトに表示するというだけのものだ。REST APIの開発はバックエンドエンジニアのBが担当し、WebフロントをFが担当していた。

『ランキングシステム』の概要

開発は順調。目立った不具合もなく『スポットランキングシステム』のリニューアル対応はリリースされた。しかし、リリースから1ヶ月後、最初のランキング集計結果が出されたあと、事件は起こった。

お客様『いつも必ずベスト3に入っている "Sタワー" が表示されていない。どういうことなんだ?』

画面に表示された上位3スポット

『トレンドが変わったのでは?』と思いながらも、Fは指摘事項の調査をはじめる。自分で書いたソースコードを開いてみた。

<script setup lang="ts">
import { onMounted, ref } from "vue";

interface RankSpot {
  rank: number;
  name: string;
}

const rankSpots = ref<RankSpot[]>([]);

onMounted(async () => {
  const results: RankSpot[] = await fetchRankSpots();
  rankSpots.value = results.sort((a, b) => (a.rank >= b.rank ? 1 : -1));
});
</script>

<template>
  <v-container class="d-flex flex-column" style="width: 480px; gap: 15px">
    <template v-if="rankSpots.length > 0">
      <v-card>
        <v-card-title>
          <span class="text-brown-lighten-2 font-weight-bold">1位</span>
          {{ rankSpots[0].name }}
        </v-card-title>
      </v-card>
      <v-card>
        <v-card-title>
          <span class="text-grey-lighten-1 font-weight-bold">2位</span>
          {{ rankSpots[1].name }}
        </v-card-title>
      </v-card>
      <v-card>
        <v-card-title>
          <span class="text-deep-orange-lighten-2 font-weight-bold">3位</span>
          {{ rankSpots[2].name }}
        </v-card-title>
      </v-card>
    </template>
  </v-container>
</template>

真っ先に疑ったのはソート処理だ。しかし results.sort((a, b) => (a.rank >= b.rank ? 1 : -1)) はランキング順位を昇順で並び替えている。RankSpot.rankもnumber型なので数値だ。問題ない。API側を調査していたBからも連絡があった。

B『APIはSタワーのrankを2と返していることを確認しました。APIの挙動は問題ありません。』

Webの実装も間違っているようには思えなかった。ただ、期待通り動かないという結果だけが残る。そもそもテストや検証は通ったはずなのに、なぜリリースしてから問題が発生するのか。担当中の案件で忙しく、疲れていたFには原因が分からなかった...。

~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆

ここから本題に入ります。

📚スキーマバリデーションライブラリ

スキーマバリデーションライブラリとは、実行時に取得したデータがスキーマ定義と一致するかどうかを検証するライブラリの総称です。JavaScript(TypeScript)は実行時に値と型を検証する機構が言語レベルで組み込まれていないため、スキーマバリデーションライブラリを利用することがあります。

スキーマバリデーションのイメージ


どこで使うのか?

外部から動的なデータを取得するときに使います。具体的には設定ファイルの読み込みや、REST APIからのデータ取得などです。

interface RankSpot {
  rank: number;
  name: string;
}

async function loadRankSpot(): Promise<RankSpot> {
  // resは実行されるまでどのような値が入るか分からない
  const res = await fetch("/ranking")
  // validateSchemaは架空の関数です. Zodの関数ではありません
  // バリデートの具体的なイメージを表現するため、ここでは使っています
  return validateSchema<RankSpot[]>(res)
}

逆に、TypeScriptを使っていて、上記以外のケースでスキーマバリデーションライブラリを使うのは冗長です。そこは型システムに任せましょう。

interface RankSpot {
  rank: number;
  name: string;
}

function loadRankSpot(): RankSpot[] {
  // 内部で静的に作成されたデータを返却する場合は
  // 型システムが保証してくれるためスキーマバリデーションライブラリは不要
  return [
    { rank: 1, name: "Tリゾート" },
    { rank: 2, name: "Sタワー" },
    { rank: 3, name: "Nランド" },
  ];
}

🤖Zod

ZodはTypeScriptファーストのスキーマ宣言および検証ライブラリです。

なぜZodを使うのか。Zodのメリットを紹介していきます。

スキーマの不整合と原因をすぐに把握できる

APIの定義するレスポンススキーマと実際に返却される値がマッチしない場合、すぐに問題がある旨と原因を把握できます。

たとえば、先ほどのコードで const res = await fetch("/ranking") が以下のようなJSONを返却したとしましょう。

[
    { rank: 1, name: "Tリゾート" },
    { rank: 2 },
    { rank: 3, name: ["Nランド", "Nシー"] }
]

Zodを使わなければ、実際に値が使われるまで問題に気づけません。気づいたあとも、原因がAPIとの接続部分であることが分かるまで時間がかかるでしょう。

Zodを使うと rankSpotSchema.array().parseAsync(res) の時点でエラーが発生します。エラーログとしては以下のメッセージが表示されます。

index.mjs:537 Uncaught (in promise) ZodError: [
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "undefined",
    "path": [
      1,
      "name"
    ],
    "message": "Required"
  },
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "array",
    "path": [
      2,
      "name"
    ],
    "message": "Expected string, received array"
  }
]
    at get error [as error] (index.mjs:537:31)
    at ZodArray.parseAsync (index.mjs:659:22)
    at async App.vue:15:19

このエラーは以下2点の問題を教えてくれています。

  • root[1]["name"] はstring型なのに、実際はundefinedとなっている

  • root[2]["name"] はstring型なのに、実際はArray型になっている

まさにその通りですね!

スキーマの齟齬には、実行時すぐ気づけるものから、しばらくしないと気づけないものまで様々です。エラーになればまだ良い方で、中にはエラーにならないまま期待とは異なる挙動を起こしてしまうケースもあります。

1つの定義で表現できる

1つの定義から、TypeScriptの型定義とスキーマバリデーションの定義をそれぞれ生成できるのが強みです。たとえば、RankSpotの場合は以下のように記述できます。

// スキーマバリデーション用の定義
const rankSpotSchema = z.object({
  rank: z.number(),
  name: z.string(),
});
// TypeScriptの型定義を以下のような1行で生成可能 (二重管理しなくよい👍)
export type RankSpot = z.infer<typeof rankSpotSchema>;

async function loadRankSpot(): Promise<RankSpot> {
  // resは実行されるまでどのような値が入るか分からない
  const res = await fetch("/ranking")
  // スキーマ変数のparseAsyncメソッドでバリデートできる
  // さらに返却値もRankSpot[]と推論される
  return rankSpotSchema.array().parseAsync(res);
}

ZodはTypeScriptの表現ほぼすべてに対応しています。利用できる機能と書き方は公式ドキュメントのBasic usage以降をご覧ください。


🤔プロローグのバグはなぜ起こったのか?

話をプロローグに戻します。バグの原因と、なぜリリースするまで気づけなかったかの理由を考察してみましょう。

既にお気づきかもしれませんが、バグが発生したのは RankSpot.rank の値がstring型になっていたからです。具体的には以下のようなJSONが返却されていました。

[
    { rank: "1", name: "Tリゾート" },
    { rank: "2", name: "Sタワー" },
    { rank: "3", name: "Nランド" },
    { rank: "10", name: "Sドーム" },
    { rank: "11", name: "M川" },
]

一方、RankSpot.rankの型定義はnumber型です。それを前提にrankSpotsの値は以下のようにソートされています。

interface RankSpot {
  rank: number;
  name: string;
}

const rankSpots = ref<RankSpot[]>([]);

onMounted(async () => {
  const results: RankSpot[] = await fetchRankSpots();
  rankSpots.value = results.sort((a, b) => (a.rank >= b.rank ? 1 : -1));
});

a.rank >= b.rank の不等式は、1桁の数であればnumber型でもstring型でも同じ結果となります。しかし2桁になるとそうはいきません。数値だと 10 >= 2 はtrueですが、文字列だと "10" >= "2" はfalseになります。つまり、ranksSpotsの値は以下のようになります。

[
    { rank: "1", name: "Tリゾート" },
    { rank: "10", name: "Sドーム" },
    { rank: "11", name: "M川" },
    { rank: "2", name: "Sタワー" },
    { rank: "3", name: "Nランド" },
]

画面は rankSpots の [0]~[2] を取得していたため、実際には10位のスポットが2位として、11位のスポットが3位として表示されてしまうわけです。

今回ご紹介した例の他に、以下のようなケースも考えられます。

  • 同じIDでもAPIによって 文字列/数値 が変わってしまうケース

  • booleanを返却するはずが文字列が返却されており if("false") が true となってしまうケース

  • 数値ではなく文字列が返却されてしまっていたため、数値同士を想定した足し算の結果が、 1 + "23" = "123" と意図せず文字列になってしまったケース

いずれも即座にエラーとはならず、見つけにくいバグです。Zodのようなスキーマバリデーションライブラリを使っていると、すぐに気づけますでの安心ですね😆

🌌エピローグ ~ゾっとした夢~

~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆

???『Fさん、起きてください Fさん...』

目を覚ますと目の前にはBがいた。

F『あれ.. いつの間にか眠ってしまったのか...。そういえばスポットランキングシステムの本番環境で起きていたバグはどうなった?』

B『バグ? 何を寝ぼけているのです... そのスポットランキングシステムは開発中じゃないですか。そうそう、私が開発したAPIをデプロイしましたので、Webの方と繋ぎこんでいただけますか?』

F『あ... ああ。どうやら長い夢を見ていたらしい。やけにリアルな夢だったが...』

重い腰を上げ、椅子に座ってパソコンに向かう。これからランキング画面を実装するところで寝落ちしてしまったらしい。

F『さて...やるか。でもその前に...』

なんとなく開いたニュースサイトである記事が目に留まる。『スキーマバリデーションライブラリZod ~君がくれたもの~』というタイトル。なぜか見覚えがある。

F『せっかくだし、Zodというのを試してみようかな』

npm i zod

F『よし、実装しよう。』

<script setup lang="ts">
import { onMounted, ref } from "vue";
import { z } from "zod";

const rankSpotSchema = z.object({
  rank: z.number(),
  name: z.string(),
});
export type RankSpot = z.infer<typeof rankSpotSchema>;

const rankSpots = ref<RankSpot[]>([]);

onMounted(async () => {
  const results = await fetchRankSpots();
  const validatedResults = await rankSpotSchema.array().parseAsync(results);
  rankSpots.value = validatedResults.sort((a, b) =>
    a.rank >= b.rank ? 1 : -1
  );
});
</script>

<template>
  <v-container class="d-flex flex-column" style="width: 480px; gap: 15px">
    <template v-if="rankSpots.length > 0">
      <v-card>
        <v-card-title>
          <span class="text-brown-lighten-2 font-weight-bold">1位</span>
          {{ rankSpots[0].name }}
        </v-card-title>
      </v-card>
      <v-card>
        <v-card-title>
          <span class="text-grey-lighten-1 font-weight-bold">2位</span>
          {{ rankSpots[1].name }}
        </v-card-title>
      </v-card>
      <v-card>
        <v-card-title>
          <span class="text-deep-orange-lighten-2 font-weight-bold">3位</span>
          {{ rankSpots[2].name }}
        </v-card-title>
      </v-card>
    </template>
  </v-container>
</template>

F『さて、実行だ....... おや、画面が表示されないな...。エラーログは...と。』

Uncaught (in promise) ZodError: [
  {
    "code": "invalid_type",
    "expected": "number",
    "received": "string",
    "path": [
      0,
      "rank"
    ],
    "message": "Expected number, received string"
  },
  {
    "code": "invalid_type",
    "expected": "number",
    "received": "string",
    "path": [
      1,
      "rank"
    ],
    "message": "Expected number, received string"
  },
  {
    "code": "invalid_type",
    "expected": "number",
    "received": "string",
    "path": [
      2,
      "rank"
    ],
    "message": "Expected number, received string"
  },
  {
    "code": "invalid_type",
    "expected": "number",
    "received": "string",
    "path": [
      3,
      "rank"
    ],
    "message": "Expected number, received string"
  },
  {
    "code": "invalid_type",
    "expected": "number",
    "received": "string",
    "path": [
      4,
      "rank"
    ],
    "message": "Expected number, received string"
  }
]
    at get error [as error] (index.mjs:537:31)
    at ZodArray.parseAsync (index.mjs:659:22)
    at async App.vue:15:28

F『こいつは...... !!!?』

事態を瞬時に把握したFは、Bの席へ向かった。

~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆~★~☆

最後までお読みいただきありがとうございました!