【kintone】Zod を使用した kintone フォームデータの型安全なバリデーション

## はじめに
最近、kintone カスタマイズの開発効率化に勤しんでいる あなP（[@annap_ms](https://x.com/annap_ms)）です。

本記事は、kintone のフォームフィールドを TypeScript で型定義して、[Zod](https://zod.dev/) を使用した型安全なバリデーションの実装について検討した備忘録になります。

Zodは、TypeScript 向けに提供されているスキーマ宣言とデータ検証のためのライブラリで、データ構造を型安全に定義し、それに基づくデータ検証を簡単に行うことができます。

## kintone のフォームデータの型定義
まずは、[kintone-dts-gen](https://cybozu.dev/ja/id/e75cd25b7908b8735c10456d) を使用して kintone のフォームデータの型定義を行います。


kintone のフィールドには、「文字列（1行）」「数値」「ドロップダウン」などのさまざまなフィールドタイプがありますが、各フィールドはそのフィールドタイプに応じて `minValue` や `maxValue` のようなバリデーション用の設定が含まれています。例えば、「文字列（1行）」「数値」「ドロップダウン」は、以下のようなプロパティを持ちます。
```ts
// 文字列（1行）
export type SingleLineText = {
	type: "SINGLE_LINE_TEXT";
	code: string;
	label: string;
	noLabel: boolean;
	required: boolean;
	defaultValue: string;
	unique: boolean;
	minLength: string;  // 最小文字数
	maxLength: string;  // 最大文字数
	expression: string;
	hideExpression: boolean;
};

// 数値
export type Number = {
	type: "NUMBER";
	code: string;
	label: string;
	noLabel: boolean;
	required: boolean;
	defaultValue: string;
	unique: boolean;
　　minValue: string;  // 最小値
  　maxValue: string;  // 最大値
	digit: boolean;
	displayScale: string;
	unit: string;
	unitPosition: "BEFORE" | "AFTER";
};

// ドロップダウン
export type Dropdown = {
	type: "DROP_DOWN";
	code: string;
	label: string;
	noLabel: boolean;
	required: boolean;
	defaultValue: string;
	options: Options;  // 選択肢
};
```
この `minValue` や `maxValue` の値は、kintone の管理画面で設定できるもので、API 経由で取得したフォームデータにも含まれます。つまり、kintone のフォームデータには **事前に設定されたバリデーションルールが存在する** ため、Zod によりこれを活用します。

また、各フィールドの型定義を統一的に管理する `OneOf` と、これらのフィールドを複数持つフォームデータを統一的に管理する `Properties` も提供されておりこれを利用することで kintone のフォームデータを型安全に扱うことができます。

```ts
export type OneOf =
	| RecordNumber
	| Creator
	| CreatedTime
	| Modifier
	| UpdatedTime
	| Category
	| Status
	| StatusAssignee
	| SingleLineText
	| Number
	| Calc
	| MultiLineText
	| RichText
	| Link
	| CheckBox
	| RadioButton
	| Dropdown
	| MultiSelect
	| File
	| Date
	| Time
	| DateTime
	| UserSelect
	| OrganizationSelect
	| GroupSelect
	| Group
	| ReferenceTable
	| Lookup
	| Subtable<{
			[fieldCode: string]: InSubtable;
	  }>;

export type Properties = {
	[fieldCode: string]: OneOf;
};
```

## ベーススキーマの定義
次に、kintone のフォームデータをバリデーションするために、フィールドごとの基本的なバリデーションルールを定義する「**ベーススキーマ**」を作成します。
このベーススキーマは、kintone の各フィールドのバリデーション設定（`minValue`, `maxLength`, `options` など）を Zod に適用するための基盤 になります。

### スキーマを定義するための型
Zod を用いたバリデーションスキーマを適用するために、まず kintone のフィールドタイプを管理する型 (`FieldTypeMap`) と、それに基づいて Zod のスキーマを適用する型 (`BaseSchemaType`) を定義します。

```ts
export type FieldTypeMap = {
	SINGLE_LINE_TEXT: SingleLineText;
	NUMBER: Number;
	DROP_DOWN: Dropdown;
	// 他のフィールドは省略
};
```
`FieldTypeMap` を定義することで、kintone の各フィールドタイプを 型安全に扱えるようになります。
また、Zod のスキーマを適用する際にも、各フィールドの型を明示的に指定できるようになります。

```ts
export type BaseSchemaType = {
	[K in keyof FieldTypeMap]: (field: FieldTypeMap[K]) => ZodTypeAny;
};
```
`BaseSchemaType`のポイントは、`FieldTypeMap` **で定義された各フィールドタイプごとに、適切な Zod のスキーマを適用する関数を持たせていること** です。
例えば、`NUMBER` フィールドには数値のバリデーション (`z.number().min().max()`) を適用し、`SINGLE_LINE_TEXT` には文字列のバリデーション (`z.string().min().max()`) を適用できます。

### ベーススキーマの定義
ここまでの型定義をもとに、実際に Zod でバリデーションルールを定義したベーススキーマ (`baseSchema`) を作成します。
```ts
import { z } from "zod";

export const baseSchema: BaseSchemaType = {
	SINGLE_LINE_TEXT: (field) =>
		z
			.string()
			.min(Number(field.minLength)) // 最小文字数
			.max(Number(field.maxLength)), // 最大文字数

	NUMBER: (field) =>
		z
			.coerce.number() // 文字列の数値も変換
			.min(Number(field.minValue)) // 最小値を適用
			.max(Number(field.maxValue)), // 最大値を適用

	DROP_DOWN: (field) =>
		Object.keys(field.options).length > 0
			? z.enum([...(Object.keys(field.options) as [string, ...string[]])]) // 選択肢の制限
			: z.string(), // 選択肢がない場合は自由入力

	// 他のフィールドは省略
};

```


## 動的なスキーマの生成
ベーススキーマを定義したら、**kintone のフォームデータから動的にスキーマを生成する仕組み** (`createFullSchema`) を作ります。

`fields`（kintone のフォームデータ）を受け取り、各フィールドの `type` に応じて `baseSchema` の適切なスキーマを取得して、`z.object()` を使ってフォーム全体のスキーマを作成する処理です。

```ts
export const createFullSchema = (fields: Properties) => {
	return z.object(
		Object.fromEntries(
			Object.entries(fields).map(([code, field]) => {
				const schema = baseSchema[field.type] as (field: OneOf) => z.ZodTypeAny;
				return [code, schema(field)];
			}),
		),
	);
};
```

ただし、実際のユースケースでは、**フォーム全体ではなく特定のフィールドのみを検証する** ケースが多いです。このような場合は、以下の `createSubsetSchema()` を利用します。

`fields`（フォームデータ）と `selectedFieldCodes`（検証対象のフィールドコードの配列）を受け取り、`createFullSchema(fields)` でまず全体のスキーマを生成したのち、`.pick(selectedFieldsObject) `を使って、指定されたフィールドのスキーマのみを抽出します。
```ts
export const createSubsetSchema = (
	fields: Properties,
	selectedFieldCodes: string[],
) => {
	const selectedFieldsObject = Object.fromEntries(
		selectedFieldCodes.map((code) => [[code], true]),
	);
	return createFullSchema(fields).pick(selectedFieldsObject);
};
```

## バリデーションの実装例
それでは、サンプルのフォームデータからスキーマを生成し、実際のユースケースとして、数値フィールド (`数値_0`) とドロップダウン (`ドロップダウン_0`) をバリデーションします。

### サンプルデータ
```ts
type FormFields = {
  properties: Properties;
};

const formFieldsResponse: FormFields = {
	properties: {
		文字列1行_0: {
			type: "SINGLE_LINE_TEXT",
			code: "文字列1行_0",
			label: "文字列 (1行)",
			noLabel: false,
			required: true,
			unique: true,
			maxLength: "64",
			minLength: "0",
			defaultValue: "",
			expression: "",
			hideExpression: false,
		},
		数値_0: {
			type: "NUMBER",
			code: "数値_0",
			label: "数値",
			noLabel: true,
			required: false,
			unique: false,
			maxValue: "64",
			minValue: "0",
			defaultValue: "",
			digit: true,
			displayScale: "",
			unit: "$",
			unitPosition: "BEFORE",
		},
		ドロップダウン_0: {
			type: "DROP_DOWN",
			code: "ドロップダウン_0",
			label: "ドロップダウン",
			noLabel: false,
			required: true,
			defaultValue: "選択肢1",
			options: {
				選択肢1: { label: "選択肢1", index: "1" },
				選択肢2: { label: "選択肢2", index: "2" },
				選択肢3: { label: "選択肢3", index: "3" },
			},
		},
	},
};
```

### 数値フィールドのバリデーション
この関数は、引数の値を 2 倍にした後、kintone の数値フィールド (`数値_0`) の制約 (`maxValue: 64`) に適合しているかチェックします。
例えば、`32` を渡すと `64` となり制約内ですが、`33` を渡すと `66` になり `maxValue` を超えるため、エラーになります。
```ts
const doubleNumberValidation = (
    inputNumber: number | string,
    targetFieldCodes: string[]
) => {
    const outputNumber = Number(inputNumber) * 2;
    const subsetSchema = createSubsetSchema(formFieldsResponse.properties, targetFieldCodes);
    const validationTarget = Object.fromEntries(
        targetFieldCodes.map((targetFieldCode) => [targetFieldCode, outputNumber])
    );
    const validationResult = subsetSchema.safeParse(validationTarget);
    if (validationResult.success) {
        return outputNumber;
    } else {
        return validationResult.error.issues.map((issue) => issue.message)[0];
    }
};
		
console.log(doubleNumberValidation(32, ["数値_0"]));  // 成功：64
console.log(doubleNumberValidation(33, ["数値_0"]));  // 失敗：Number must be less than or equal to 64
```

### ドロップダウンフィールドのバリデーション
この関数は、kintone の ドロップダウンフィールド (`ドロップダウン_0`) の選択肢 (`options`) に基づいて、入力値が正しいかどうかを検証 します。
サンプルデータでは `選択肢1`、`選択肢2`、`選択肢3` のみが設定されているため、それ以外の値 (`"選択肢5"`) を入力するとエラーになります。
```ts
const dropdownValidation = (
    option: string,
    targetFieldCodes: string[]
) => {
    const subsetSchema = createSubsetSchema(formFieldsResponse.properties, targetFieldCodes);
    const validationTarget = Object.fromEntries(
        targetFieldCodes.map((targetFieldCode) => [targetFieldCode, option])
    );
    const validationResult = subsetSchema.safeParse(validationTarget);
    if (validationResult.success) {
        return option;
    } else {
        return validationResult.error.issues.map((issue) => issue.message)[0];
    }
};

console.log(dropdownValidation("選択肢1", ["ドロップダウン_0"]));  // 成功：選択肢1
console.log(dropdownValidation("選択肢5", ["ドロップダウン_0"]));  // 失敗：Invalid enum value. Expected '選択肢1' | '選択肢2' | '選択肢3', received '選択肢5'
```



## さいごに
今回は、Zod を使用した kintone フォームデータの型安全なバリデーションについて検討しました。実際は、毎回 kintone REST API をたたいてフォームデータを取得するわけにはいかないので、生成したスキーマを静的に管理する方法やアプリの管理画面からフォームデータを変更したときにスキーマを更新する仕組みを作成する必要がありそうです。機会があればこれらについても検討して記事にしてみようと思います。

コーディングはまだまだ初心者なので、もっと型安全な書き方があればぜひ教えていただきたいです！最後まで読んでいただきありがとうございました！