TypeScriptのCDKスタックをZodでバリデーションする

概要

TypeScriptで書かれたCDKスタックのバリデーションを、Zodというバリデーションツールでバリデートするようにしてみました。


目次

目次


前提

TypeScriptでCDKを使用しています。

また、CDKはv2を使用しております。

❯ cdk --version
2.31.0 (build b67950d)


Zodとは

ざっくり言うと、TypeScriptのバリデーションツールです。

github.com


リクエストなどのオブジェクト情報を、定義したtypeのオブジェクトにパースするタイミングで、定義したルールに基づいてバリデーションを行えるツールです。

type定義と同じファイルでバリデーションルールが書けるので、非常に楽にバリデーションを行うことができます。

import { z } from "zod";

export const RequestSchema = z.object({
  title: z.number(),
  message: z.string().min(1),
  mailAddress: z.string().email().min(1),
  tags: z.array(z.string().min(1)).optional(),
});

export type Request = z.infer<typeof RequestSchema>;
// ここでバリデーションも同時に行われる
const request: Request = RequestSchema.parse(body);


上記のように、型・オプショナル(.optional())・空文字チェック(.min(1))だけでなく、email形式のハンドリング(.email())なども行うことができます。


バリデーションエラー時は、以下のようなエラーが出力されます。

{
  "issues": [
    {
      "validation": "email",
      "code": "invalid_string",
      "message": "Invalid email",
      "path": [
        "mailAddress"
      ]
    }
  ],
  "name": "ZodError"
}


コード例

さっそくコード例です。

メインとなる3ファイルを例として取り上げます。

  • Zodスキーマ
  • スタッククラス
  • バリデータークラス


下記のコードでバリデーションを実装すると、定義したバリデーションがcdk synth時に行われます。


Zodスキーマ

まずはこちらで、バリデートを行いたいパラメータをZodスキーマとして定義し、Zodによるバリデーションルールスキーマのプロパティに対して定義します。

また、z.infer<typeof StackInputSchema>とすることでスキーマからtypeを定義できるため、スタッククラスやバリデータークラスで扱う際に型として使えて便利です。

import { z } from "zod";

export const StackInputSchema = z.object({
  topicName: z.string().min(1),
  mailAddress: z.string().email().min(1),
});

export type StackInput = z.infer<typeof StackInputSchema>;


スタッククラス

テキトーな例ですが、init()でバリデート、create()でリソースの作成を行なっています。

先程定義したStackInput typeを使ってバリデーション用の変数として定義し、それをそのままvalidatorのコンストラクタに渡しています。

export class SampleStack extends Stack {
  private stackInput: StackInput;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    this.init();
    this.create();
  }

  private init(): void {
    this.stackInput = {
      topicName: props.config.topicName, // スタック外部からStackProps経由でパラメータを渡す
      mailAddress: props.config.mailAddress,
    };

    const stackValidator = new StackValidator(this.stackInput);
    this.node.addValidation(stackValidator);
  }

  private create() {
  // ...(省略)


init()内でバリデータークラスのインスタンスを生成バリデート対象の情報をコンストラクタで渡しthis.node.addValidationというメソッドにインスタンスを渡しています。

これはCDKで提供されているバリデートメソッドで、複数のバリデーションが登録可能で、cdk synth時にバリデーションが走ってくれるイメージです。

詳細は以下の記事で説明しています。

go-to-k.hatenablog.com


バリデータークラス

こちらは具体的なバリデート処理を書くクラスで、validate()内でZodによるバリデーション処理を行なっています。

このようにバリデータークラスとしてスタッククラスから切り出すことによって、もしZodを使うのをやめてバリデート方法を変える場合もスタッククラス自体には手を加えなくて良いため、保守性が高くなるメリットがあります。

import { IValidation } from "constructs";
import { StackInput, StackInputSchema } from "../types/stack-input";

export class StackValidator implements IValidation {
  private stackInput: StackInput;

  constructor(stackInput: StackInput) {
    this.stackInput = stackInput;
  }

  public validate(): string[] {
    const errors: string[] = [];

    try {
      StackInputSchema.parse(this.stackInput);
    } catch (e) {
      errors.push(JSON.stringify(e));
    }

    return errors;
  }
}


後述するZodのスキーマファイルで定義したスキーマのparseメソッドにバリデーション対象のオブジェクトを渡すことで、パースとバリデーションを一緒に行ってくれます。

成功すれば、同じく定義するtypeの変数にオブジェクトが格納され、失敗すればZodのバリデーションエラーが吐かれます。

StackInputSchema.parse(this.stackInput);


バリデーション実行

それでは、早速わざとメールアドレスの形式を不適切(abcde.fgh)にして、cdk synthを実行してみました。

/Users/goto/github/mail-queues/node_modules/aws-cdk-lib/core/lib/private/synthesis.js:2
  `);throw new Error(`Validation failed with the following errors:
           ^
Error: Validation failed with the following errors:
  [SampleStack] {"issues":[{"validation":"email","code":"invalid_string","message":"Invalid email","path":["senderAddress"]}],"name":"ZodError"}
    at validateTree (/Users/goto/github/mail-queues/node_modules/aws-cdk-lib/core/lib/private/synthesis.js:2:12)
    at Object.synthesize (/Users/goto/github/mail-queues/node_modules/aws-cdk-lib/core/lib/private/synthesis.js:1:598)
    at App.synth (/Users/goto/github/mail-queues/node_modules/aws-cdk-lib/core/lib/stage.js:1:1866)
    at process.<anonymous> (/Users/goto/github/mail-queues/node_modules/aws-cdk-lib/core/lib/app.js:1:1164)
    at Object.onceWrapper (events.js:520:26)
    at process.emit (events.js:400:28)
    at process.emit (domain.js:475:12)
    at process.emit.sharedData.processEmitHook.installedValue [as emit] (/Users/goto/github/mail-queues/node_modules/@cspotcode/source-map-support/source-map-support.js:745:40)

Subprocess exited with error 1


Zodのスキーマで定義した通り、emailバリデーションに引っかかってくれて、エラーが出力されました。

めでたしめでたし。


おまけ: コンパイル失敗!

Zodを使ってCDKのバリデーションを行うことに関しては以上なのですが、実は実際に動かしてみるとコンパイルに失敗してしまいました。


エラーメッセージ

✦ ❯ cdk synth
⨯ Unable to compile TypeScript:
lib/types/stack-input.ts:3:33 - error TS2589: Type instantiation is excessively deep and possibly infinite.

  3 export const StackInputSchema = z.object({
                                    ~~~~~~~~~~
  4   topicName: z.string().min(1),
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  5   mailAddress: z.string().email().min(1),
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  6 });
    ~~


Subprocess exited with error 1


バージョン

突然なのですが、ここでバージョンを確認してみましょう。

  • Zod : 3.17.3
  • TypeScript : 3.9.10


解決

説明をすごく端折りますが、TypeScriptのバージョンをあげたら解決しました(Zodはそのまま)。

  • Zod : 3.17.3
  • TypeScript : 3.9.10 -> 4.7.4


※上記エラーでググったらissueなどが出たのですが、ここでは触れません。


応用編

Zodをさらに応用して、TypeScriptの型保証をZodスキーマで作った型で賄っちゃおうっていう、CDKスタックへのパラメータの型保証と制約を一緒にやっちゃうという話も記事にしました。

go-to-k.hatenablog.com


登壇してきました

2023-05-21開催「AWS CDK Conference Japan 2023」で「AWS CDKとZodを活用したバリデーションパターン集」という内容で登壇してきました。

speakerdeck.com


最後に

前回CDKでバリデーションを行う記事を書いたのですが、今回はさらにTypeScriptにフォーカスを置いて、便利なZodを入れてみました。

やっぱりバリデーションをフレームワークに任せるのは楽ですね。