⚙️

Zodスキーマをもとにクラスを定義するライブラリを公開しました【TypeScript】

2024/07/16に公開

概要

https://www.npmjs.com/package/validated-extendable

いちばん基本の使用例
import { Validated } from "validated-extendable";
import { z } from "zod";

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

/* Zodスキーマをもとにクラスを定義できる */
class Person extends Validated(schema) {
  greet() {
    console.log(`Hello, I'm ${this.name}.`);
  }

  isAdult() {
    return this.age >= 18;
  }
}

/* スキーマをもとに型付けされたコンストラクタが作られる */
// const person = new Person({ age: 25 }); // => Compile error: Property 'name' is missing in type '{ age: number; }' but required in type '{ name: string; age: number; }'.

/* コンストラクタなどでバリデーションが行われる */
const person = new Person({ name: "John", age: 25 });
// const invalidPerson = new Person({ name: "", age: -1 }); // => Throws an error

背景

TypeScriptでのバリデーションといえばZodが有名ですね。

https://zenn.dev/uttk/articles/bd264fa884e026

ところでみなさんも、不正なプロパティを持ったインスタンスが作られるのを防ぐために、以下のようにコンストラクタでバリデーションした引数をプロパティに詰め替えるコードを書いたことがあるかもしれません。(DDDでの値オブジェクトなどでよく見かけそうですね。)

class Person {
  public readonly name: string;
  public readonly age: number;
  public readonly email: string;
  public readonly status: "active" | "inactive";

  // バリデーションした引数をプロパティに詰め替えるコンストラクタの一例(他にも書き方があると思います)
  constructor(params: { name: string, age: number, email: string, status: string) {
    this.name = z.string.min(1).parse(params.name);
    this.age = z.number().nonnegative().int().parse(params.age);
    this.email = z.string().email().parse(params.email);
    this.status = z.enum(["active", "inactive"]).parse(params.status);
  }
}

なんてことはないボイラープレートコードですが、それなりの規模のアプリケーションならバリデーションしなければならないクラスの数は膨大になるかもしれません。段々と、同じプロパティの名前何回書かないといけないの?という気持ちになりそうです。
わたしたちは心を無にしてコードを書くこともできますが、できることなら例えばZodスキーマをそのままクラスの定義に変換したりしてしまいたい。

ということでこちらのライブラリをつくりました。

https://www.npmjs.com/package/validated-extendable

使い方

ベーシック

import { Validated } from "validated-extendable";
import { z } from "zod";

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

/* Zodスキーマをもとにクラスを定義できます */
class Person extends Validated(schema) {
  greet() {
    console.log(`Hello, I'm ${this.name}.`);
  }
}

/* スキーマをもとに型付けされたコンストラクタが作られます */
// const person = new Person({ age: 25 }); // => Compile error: Property 'name' is missing in type '{ age: number; }' but required in type '{ name: string; age: number; }'.


/* コンストラクタがスキーマを使ってバリデーションを行います */
const person = new Person({ name: "John", age: 25 });
// const invalidPerson = new Person({ name: "", age: -1 }); // => Throws an error

/* すべてのプロパティは普段のようにアクセス可能で、スキーマから推論された型がついています */
console.log(person.name, person.age); // => John 25

/* static プロパティ 'schema' によって元のZodスキーマを取得できます */
Person.schema.parse({ name: "John", age: 25 }); // => { name: 'John', age: 25 }

ミュータブルなプロパティ

デフォルトでは、すべてのプロパティは readonly です。プロパティを変更したい場合は、Validatedの代わりにValidatedMutableを使います。

import { ValidatedMutable } from "validated-extendable";
import { z } from "zod";

class Person extends ValidatedMutable(
  z.object({
    name: z.string().min(1),
    age: z.number().nonnegative().int(),
  })
) {
  greet() {
    console.log(`Hello, I'm ${this.name}.`);
  }
}

const person = new Person({ name: "John", age: 25 });

/* プロパティの変更も、コンストラクタと同様にバリデーションされます */
person.name = "Jane";
// person.age = -1; // => Throws an error

プリミティブ型

ValidatedValidatedMutableはどちらも、z.string(), z.number(), z.boolean()などのプリミティブ型のスキーマをサポートしています。

import { Validated } from "validated-extendable";
import { z } from "zod";

class Age extends Validated(z.number().nonnegative().int()) {
  isAdult() {
    /* プロパティ 'value' によって、プリミティブな値にアクセスできます */
    return this.value >= 18;
  }
}

const age = new Age(25);
// const invalidAge = new Age(-1); // => Throws an error

console.log(age.value); // => 25

余談

コンストラクタで他のクラスのインスタンスを受け取りたいときは

z.instanceof()をスキーマで使います。

class Article extends ValidatedMutable(
  z.object({
    title: z.string().min(1),
    /* 'Date' クラスは、'z.date()' でも受け取れるので、これは例として微妙かも? */
    postedAt: z.instanceof(Date),
  }),
) {}

const article = new Article({
  title: "Hello",
  postedAt: new Date("2021-01-01"),
});

制約

デフォルトでは、バリデーションの結果の型は(プリミティブ型を除けば)継承が可能な型である必要があります。(例えば、ユニオン型やタプル型はTypeScriptにおいては継承できません。)

ただし、オプションwrapValueを使うことで、継承できない型を(上記のプリミティブ型の例のように)オブジェクトでラップして継承できるようにすることができます。

import { Validated } from "validated-extendable";
import { z } from "zod";

/* バリデーションの結果の型は '{ success: true, message: string } | { success: false, error: string }' */
const schema = z.discriminatedUnion("success", [
  z.object({
    success: z.literal(true),
    message: z.string(),
  }),
  z.object({
    success: z.literal(false),
    error: z.string(),
  }),
]);

/* 継承できない型をextendsするために、'wrapValue' オプションを使用します
   'wrapValue' を使わずに 'Result extends Validated(schema)' と書くと型検査の際に、継承できない型を継承しようとしているとしてエラーが発生します */
class Result extends Validated(schema, { wrapValue: true }) {
  getError(): string | undefined {
    /* プロパティ 'value' によってラップされた値にアクセスできます */
    return !this.value.success ? this.value.error : undefined;
  }
}

const failure = new Result({ success: false, error: "Oops!" });
console.log(failure.getError()); // => Oops!

ValidatedMutableが提供するセッターのバリデーションは、孫プロパティ以下のプロパティへの代入に対しては呼び出されません。

import { ValidatedMutable } from "validated-extendable";
import { z } from "zod";

const schema = z.object({
  foo: z.number().nonnegative().int(),
  bar: z.object({
    baz: z.number().nonnegative().int(),
  }),
});

class Foo extends ValidatedMutable(schema) {}

const x = new Foo({ foo: 1, bar: { baz: 2 } });

/* これはバリデーションされる */
// x.foo = -1; // => Throws an error

/* これもバリデーションされる */
// x.bar = { baz: -1 }; // => Throws an error

/* これはバリデーションされない */
x.bar.baz = -1; // => Throws no error!!!
import { ValidatedMutable } from "validated-extendable";
import { z } from "zod";

const schema = z.object({
  foo: z.number().nonnegative().int(),
  bar: z.object({
    baz: z.number().nonnegative().int(),
  }),
});

class Foo2 extends ValidatedMutable(schema, { wrapValue: true }) {}

const x = new Foo2({ foo: 1, bar: { baz: 2 } });

/* これはバリデーションされる */
// x.value = { foo: 1, bar: { baz: -1 } }; // => Throws an error

/* これもバリデーションされる */
// x.value.foo = -1; // => Throws an error

/* これもバリデーションされる */
// x.value.bar = { baz: -1 }; // => Throws an error

/* これはバリデーションされない */
x.value.bar.baz = -1; // => Throws no error!!!

仕組み

こちらの記事でとても詳しく解説されているように、extendsのあとにくるのはクラスのコンストラクタである必要があります。(extendsのあとには一見型を指定しているように見えますが、ここで指定されているのはコンストラクタの値です。)
多少の制約はありますが、それがコンストラクタとしての体裁を保っている関数であれば、色々な値をextendsの後に置くことができます。

https://qiita.com/qnighy/items/af85b91a9235a0cd4a58

ValidatedValidatedMutableはZodスキーマを受け取りコンストラクタを返す関数です。そのため、Validated(schema)をクラスで継承することができます。

export type ValidatedConstructor<
  Schema extends ZodSchema<unknown, ZodTypeDef, unknown>,
> =// 'Validated(schema)' が返すコンストラクタ(のような値)の型定義
;

// 'Validated'のシグネチャ(イメージ): スキーマを受け取って、コンストラクタを返す関数
export const Validated: <
  Schema extends ZodSchema<unknown, ZodTypeDef, unknown>,
>(
  schema: Schema,
) => ValidatedConstructor<Schema> =;

リポジトリ

https://github.com/takagiy/validated-extendable.js

npmパッケージの作成は手探りだったので、特にモジュール解決まわりがきちんと対応できているか一抹の不安があります。
何かありましたらぜひissueをよろしくおねがいします🙇

Discussion