🐣

ひよっこエンジニアが Node のアップデートに立ち向かったら Zod でハマった話

2023/07/16に公開

はじめに

駆け出して約1ヶ月ほどのひよこエンジニアです。

Vercel の以下のアナウンスを受けて、社内のフロントエンド開発で利用している Node.js を v14 から v18 にアップデートしましたので、そのときにやったことやハマったことを簡単にまとめていきます。

https://vercel.com/changelog/node-js-14-and-16-are-being-deprecated

方針

そもそも Node.js をアップデートするときに同時に考慮すべき問題としては、OS 起因の問題や Node.js Runtime 由来の問題など様々あります。

が、だいたいは 「プロジェクトで利用しているライブラリの問題」 にぶつかることが多いです。(以下記事など参照。また今回担当しているプロジェクトでは Docker イメージを用いていたため OS 起因の問題などは起こり得ない。)

https://efcl.info/2023/04/29/node.js-14-to-18/

そのため、基本的には 「依存ライブラリの更新」 をメインで進めていくことにしました。

ただとはいえ

「とりあえずyarn upgradeして package.json に記載されている範囲内でバージョンをすべてあげてしまおう」

とやってしまうのはあまりよろしくなく、例えば UI ライブラリにまで影響が及んでしまい、見た目や挙動に影響が出てしまう恐れがあります。

こういったことも考慮し、実際には (アップデートに必要な最小限の)ライブラリを更新していく」 という方針で進めていきました。

具体的な作業内容

作業手順としては、以下の 3 ステップ で行いました。

1. CI 環境と ローカル開発環境用の Docker イメージを v18 へ更新

まず最初に CI 環境で使用する Docker イメージとローカル開発用の Docker イメージを v18 に更新しました。

ci.yml
jobs:
  tsc:
    name: TypeScript Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Set up node.js
        uses: actions/setup-node@v2
        with:
-         node-version: 14.17.0-alpine
+         node-version: 18.16.0
          cache: yarn
docker-compose.yml
version: '3'
services:
  <project-name>:
    container_name: <project-name>
-   image: node:14.17.0-alpine
+   image: node:18.16.0

2. 動作しないライブラリの一部を更新 or 置換する

この状態でローカル環境を立ち上げると、動作しないライブラリが出てくるので更新していきます。

具体的には

yarn upgrade <パッケージ名>

と入力して package.json に指定されている依存ライブラリを semver の範囲内で最新のバージョンに更新します。

https://classic.yarnpkg.com/lang/en/docs/cli/upgrade/

ただ一部のライブラリに関しては今後の開発運用のコストなどを考えて、思い切って代替することにしました。

具体的には、Yup とその周辺ライブラリに関しては、同プロジェクト内ですでに最新版がインストールされ使用されていた Zod に置き換えることにしました。
(また現在自分が携わっているプロジェクトでは Yup と Zod を何故か両方利用していたため、統一したかったという意図もある)

https://zod.dev/

3. 依存ライブラリの脆弱性対応

最後に、脆弱性のあるライブラリを調査し、対応しました。

yarnを使用している場合は、以下のコマンドによりアプリケーション実行時に使用するものに絞り込んでライブラリに脆弱性がないか検査を行ってくれます。

$ yarn audit --groups dependencies

https://classic.yarnpkg.com/lang/en/docs/cli/audit/

これにより脆弱性のあるライブラリの一覧と内訳がレベル別に表示されるので、レベルの高いものから順に更新を検討していきました。(具体的には Low Moderate High Critical の 4 段階に分類されます。)

┌───────────────┬──────────────────────────────────────────────────────────────┐
│ high          │ decode-uri-component vulnerable to Denial of Service (DoS)   │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package       │ decode-uri-component                                         │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Patched in>=0.2.1                                                      │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ query-string                                                 │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path          │ query-string > decode-uri-component                          │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info     │ https://www.npmjs.com/advisories/1091652                     │
└───────────────┴──────────────────────────────────────────────────────────────┘
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ moderate      │ semver vulnerable to Regular Expression Denial of Service    │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package       │ semver                                                       │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Patched in>=7.5.2                                                      │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ npm-run-all                                                  │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path          │ npm-run-all > cross-spawn > semver                           │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info     │ https://www.npmjs.com/advisories/1092310                     │
└───────────────┴──────────────────────────────────────────────────────────────┘
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ moderate      │ semver vulnerable to Regular Expression Denial of Service    │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package       │ semver                                                       │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Patched in>=7.5.2                                                      │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ npm-run-all                                                  │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path          │ npm-run-all > read-pkg > normalize-package-data > semver     │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info     │ https://www.npmjs.com/advisories/1092310                     │
└───────────────┴──────────────────────────────────────────────────────────────┘
6 vulnerabilities found - Packages audited: 390
Severity: 2 Moderate | 1 High
Done in 1.26s.

詳細は省きますが、ここでの更新作業は以下のような方針で進めていきました。

  1. yarn upgrade で脆弱性の見つかったライブラリ本体を更新して脆弱性解消
  2. 1 でうまく行かない場合、依存関係を調査して脆弱性の見つかったライブラリを必要としている依存元のライブラリを更新(以下のtextlintを更新する感じ。)
$ npm ls trim

└─┬ textlint@11.9.1
  └─┬ @textlint/textlint-plugin-markdown@5.3.5
    └─┬ @textlint/markdown-to-ast@6.3.5
      └─┬ remark-parse@5.0.0
        └── trim@0.0.1
  1. 2 でうまく行かない場合、yarn.lockファイル内を直接書き換えて、間接的な依存パッケージのみをアップデート
  2. それでもうまく行かない場合、例えばメンテがずっと行われていないライブラリが大元にいたり、間接的に依存しているライブラリのバージョンが上がらない場合は依存パッケージのバージョンをresolutionsで指定(以下はあくまで例です。)
package.json
...
"dependencies": {
  ...
},
"resolutions": {
  "pm2/mkdirp/minimist": "^0.2.4"
}

詳しくは以下の記事などを参照してください。
https://zenn.dev/ymmt1089/articles/20221120_node_vulnerability
https://numb86-tech.hatenablog.com/entry/2020/05/26/170627
https://qiita.com/uasi/items/ca440a750a77ca62321b

Zod でハマったこと

先程も少し触れましたが、今後の運用コストなどを考えてバリデーションライブラリとして Yup と Zod を混合して使用していたところを Zod で統一することにしました。

ただ Yup と Zod では互換性がない部分が一部存在していたため、スキーマをただ置き換えていくだけではうまく行かないケースが結構存在しています。

そのあたりの調整になかなか苦労しましたので、ハマったポイントを紹介していきます。

数値型の扱い

まずは数値型の扱いです。

具体的には、Number 型のスキーマの検証前処理が Yup と Zod では違っていました。

Yup の場合、検証を行う前に withMutation メソッドが内部的に呼び出され、値が文字列である場合、文字列から空白を除去しその結果を数値に変換します。

Yup の NubmerSchema の定義
export default class NumberSchema<
  TType extends Maybe<number> = number | undefined,
  TContext = AnyObject,
  TDefault = undefined,
  TFlags extends Flags = '',
> extends Schema<TType, TContext, TDefault, TFlags> {
  // スキーマ生成処理
 constructor() {
    super({
      type: 'number',
      check(value: any): value is NonNullable<TType> {
        if (value instanceof Number) value = value.valueOf();

        return typeof value === 'number' && !isNaN(value);
      },
    });

    // ここが検証前に呼び出される
    this.withMutation(() => {
      this.transform((value, _raw, ctx) => {
        if (!ctx.spec.coerce) return value;

        let parsed = value;

        // 値が文字列である場合は数値に変換される
        if (typeof parsed === 'string') {
          parsed = parsed.replace(/\s/g, '');
          if (parsed === '') return NaN;
          // don't use parseFloat to avoid positives on alpha-numeric strings
          parsed = +parsed;
        }

        // null -> NaN isn't useful; treat all nulls as null and let it fail on
        // nullability check vs TypeErrors
        if (ctx.isType(parsed) || parsed === null) return parsed;

        return parseFloat(parsed);
      });
    });
  }

一方で Zod で Number 型のスキーマを定義する場合、デフォルトではこのような事前処理が行われず、フォームにバインディングされる値は常に String 型として扱われます。

そのため、入力された値を数値として扱う場合にはどこかで Number 型に変換する必要が出てきます。 今回はフォームの値を管理するために「React Hook Form」を用いていたため、フォーム側で数値に変換することにしました。

具体的には、register オプションvalueAsNumbersetValueAsを用いて実装することが可能です。

以下のサンプルコードですと、フォーム側で age を数値に変換するため setValueAs を設定しています。

Form.tsx
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import React from "react";

const UserSchema = z.object({
  name: z.string(),
  age: z.number().positive(),
});

type User = z.infer<typeof UserSchema>;

export const Form: React.FC = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<User>({
    resolver: zodResolver(UserSchema),
    mode: "onTouched",
    defaultValues: {
      name: "",
      age: undefined,
    },
  });
  const onSubmit: SubmitHandler<User> = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label htmlFor="age">Age</label>
      <input
        {...register("age", {
          setValueAs: (value) => {
            if (value.trim() === "") {
              return NaN;
            }
            return Number(value);
          },
        })}
        id="age"
        aria-invalid={errors.age ? "true" : "false"}
        aria-describedby="valid-age positive-age"
      />

      {errors.age?.type === "invalid_type" && (
        <p role="alert" id="valid-age">
          年齢は整数で入力してください。
        </p>
      )}

      {errors.age?.type === "too_small" && (
        <p role="alert" id="positive-age">
          年齢は正の数で入力してください。
        </p>
      )}

      <button type="submit">Submit</button>
    </form>
  );
};

相関チェック

お次に苦労したのが「複数項目の相関チェック」です。

Yup だとフォームの他のフィールドの値によってバリデーションを実施したい場合には、基本的には test メソッド内で他フィールドの情報を参照することが可能です。

以下の例だと 18 歳未満であることを agree フィールドの test メソッド内にて表現し、バリデーションを実施するかどうか決定しています。

相関チェック(Yup)
import * as yup from "yup";

export const RegistrationSchema = yup.object({
  name: yup.string().min(3).notRequired(),
  age: yup.number().positive().integer().notRequired(),
  agree: yup
    .boolean()
    .notRequired()
    .test("age", "You must be 18 or older to agree", function (agree) {
      const { age } = this.parent;
      return age >= 18 || agree;
    }),
});

export type Registration = {
  name: string;
  age: number;
  agree: boolean;
};

一方、Zod で相関チェックを行う場合は以下のように Object スキーマ全体の定義後に refine() メソッド内の条件分岐でバリデーションを実行するかどうか決定する必要があります。

相関チェック(Zod)
import { z } from 'zod';

export const RegistrationSchema = z
  .object({
    name: z.string().min(3),
    age: z.coerce.number().positive().int(),
    agree: z.boolean().optional(),
  })
  .refine(({ age, agree }) => age >= 18 || agree, {
    path: ['agree'],
    message: 'You must be 18 or older to agree',
  });

export type Registration = z.infer<typeof RegistrationSchema>;

既存の型との不整合

最後が「既存の型との不整合」です。これは Zod を用いるとき、多くの人が1度は直面する問題ではないかと思います。

具体的には OpenAPI Generatorなどを用いて自動生成された既存の型情報がある場合は、Zod のスキーマと整合性を合わせる必要があります。

これだけだと少し伝わりづらいかと思いますので順を追って説明していきます。

まず Yup と Zod においてスキーマに定義されていないフィールドの値が検証時にどうなるか?見てみましょう。

例えば、以下のようなデータを検証する場合を考えてみます。

const data = {
  name: "John Doe",
  age: 30,
  email: "john.doe@example.com", // このフィールドはスキーマに定義されていません
};

emailフィールドがスキーマに未定義の状態だと仮定します。

このとき Yup を利用して検証をパスさせてみると、返却値にはdataで定義したフィールドが全て含まれています。

const data = {
  name: "John Doe",
  age: 30,
  email: "john.doe@example.com", // このフィールドはスキーマに定義されていません
};

const schema = object({
  name: string(),
  age: number(),
});

const result = schema.validateSync(data);
console.log(result); // 出力:{ name: 'John Doe', age: 30, email: "john.doe@example.com" }

一方で Zod を利用するとどうでしょう?
実は Zod だとより厳密な型チェックが行われるため、スキーマに定義されていないフィールドはデフォルトだと削除されてしまいます。

const schema = z.object({
  name: z.string(),
  age: z.number(),
});

const data = {
  name: "John Doe",
  age: 30,
  email: "john.doe@example.com", // このフィールドはスキーマに定義されていません
};

const result = schema.parse(data);
console.log(result); // 出力:{ name: 'John Doe', age: 30 }

このため、例えば既存型情報の中で Optional で定義されているフィールド値があり、さらにバリデーションなども設ける必要がなかったとしても Zod ではスキーマにすべてのフィールドを厳密に定義する必要があります。

もしこれをせず Yup と同じ感覚でスキーマを定義してしまうと、最悪の場合以下のように入力したデータが送信時に含まれないということが起こってしまいます。



これの解決策としては色々考えられるのですが、1 番簡単な方法としてはスキーマ検証後に得られる値ではなく生の入力値を返すように設定することです。

React Hook Form と組み合わせて使用している場合は、zodResolverrawオプション(あるいはrawValuesオプション) を設定することで可能です。

上記のuseForm部分を抜粋
const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<Registration>({
  resolver: zodResolver(RegistrationSchema, undefined, { raw: true }),
});

学んだこと

ライブラリを定期的にメンテする仕組みを整える

今回 Node.js のアップデートそのものでは特に大きな問題は発生せず、問題の大半は依存ライブラリ関連のものでした。

このことから定期的にライブラリをメンテナンスする仕組みを作ることはやはり大切だと強く実感しました。

現状、自分が関わっているプロジェクトでは Dependabot を入れているのですが、それをどう運用していくのか?の指針がまだはっきりと定まっていないため、Pull Request がどんどん滞留していくという状態です...

今後はこのあたりの仕組みもちゃんと整えていきたいです。

テストを書く

今回1番大変だったのが、Yup から Zod への置き換えでした。

バリデーションに関するスキーマをほぼすべて置き換えたため影響範囲もかなり広く、それらを手動で動作確認するのはかなり大変でした。
(1 人ではさすがに不安だったため、チームメンバー全員にも確認していただきました。)

フロントエンド開発であっても今後は E2E テストや結合テストなどのテストコードを充実させておくことで、こういった変更にもしっかりと対応できるようにしていきたいところです。

おわりに

以上ひよっこエンジニアが Node.js のアップデートに立ち向かった話でした。

ひよっこ故に苦労することも多かったですが、社内の強強エンジニアの方の手を借りたり、ドキュメントを読み漁ったりすることでなんとか終わらせることができました。
いや、ほんと Zod への置き換えは大変だった..

もし Node のアップデートや Yup→Zod を考えている方がいらっしゃったら、参考になれば嬉しいです。

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

その他参考にさせていただいた記事

https://qiita.com/qurysan/items/a8dfdbe6891d2951c5ac
https://developers.play.jp/entry/2023/03/10/144000
https://azukiazusa.dev/blog/react-hook-form-zod-5-patterns/
https://zenn.dev/ynakamura/articles/65d58863563fbc
https://tech.buysell-technologies.com/entry/2023/01/30/000000

COUNTERWORKS テックブログ

Discussion