先に結論
- ajv.compileSchema() は内部で new Function() しており、CSP(Content-Security-Policy)次第では動作しない。そこで standaloneCode を利用した事前ビルドが必要
- ビルド設定はviteを使う。ts-nodeなどでも可能だが、ビルド処理の一環として行いたいのでviteを選んだ
※正直に言えばts-nodeの設定が面倒だったから - JSON Schema は JSONSchemaType を使えば TypeScript に型情報を付与できる。ただし、as const による静的 JSON 化が必要になる
- standaloneCode の出力は .cjs 形式になるため、import は動かず require() を使って読み込む必要がある。
- 型検証の代替として、定義済みの Schema 定数と .json を toEqual() で比較するテストを用意する
viteプラグイン
サンプルコード
例外処理などは省略しています。必要に応じて調整してください。MIT License で公開します。// build/plugins/validateSchemaPlugin.ts
import fg from "fast-glob";
import fs from "fs/promises";
import path from "path";
import Ajv from "ajv";
import standaloneCode from "ajv/dist/standalone";
import { PluginOption } from "vite";
const build = async (schemaPath: string): Promise<void> => {
const fullPath = path.resolve(schemaPath);
// e.g. person/person.schema.json → person/validatePerson.ts
const parsed = path.parse(fullPath);
const fileBase = parsed.name.replace(/\.schema$/, ""); // e.g. "person"
const filename = `${fileBase}Validate`;
async function writeCjsFile() {
const schemaText = await fs.readFile(fullPath, "utf-8");
const schemaJson = JSON.parse(schemaText);
const ajv = new Ajv({
code: { source: true, esm: false },
strict: true,
});
const validate = ajv.compile(schemaJson);
const standalone: string = standaloneCode(ajv, validate);
const targetFile = path.join(parsed.dir, `${filename}.cjs`);
await fs.writeFile(targetFile, standalone, "utf-8");
}
async function writeDtsFile() {
const dtsFilename = path.join(parsed.dir, `${filename}.d.ts`);
const dtsContent: string = [
`declare const validate: (data: unknown) => boolean;`,
"export = validate;",
"// This file is auto-generated by the validateSchemaPlugin.",
].join("\n");
await fs.writeFile(dtsFilename, dtsContent, "utf-8");
}
writeCjsFile();
writeDtsFile();
};
export function validateSchemaPlugin(): PluginOption {
return {
name: "vite-plugin-validate-schema",
apply: "build",
enforce: "pre",
async buildStart() {
console.log("バリデーション関数の生成を開始...");
const schemaPaths = await fg("src/**/!(*.d).schema.json");
for (const schemaPath of schemaPaths) {
build(schemaPath);
}
console.log("バリデーション関数の生成が完了しました。");
},
};
}
1: 事前コンパイルの理由とメリット
ajv.compileSchema() は内部で new Function() を使用しており、これは CSP によって禁止される可能性があります。
CSP(Content-Security-Policy)は HTML の タグで設定可能で、セキュリティの観点から eval() や new Function() のような動的コード生成を禁止できます。
そのため、AJV 公式でもstandaloneCode の使用が推奨されています。
また、事前にビルドすることでアプリケーションの起動速度も向上します。コンパイル処理が事前に済んでいるからです。
2: コード自動生成を準備する
- JSON Schema ファイルを fast-glob で収集
- JSON を読み込み
- AJV で validate 関数を生成
- .cjs ファイルとして出力
- .d.ts を併せて生成し、型情報を補完
.cjs を選んだのは ESM 出力でのテストトラブルを回避するためです。
3: 生成したコードを組み込む
.cjs ファイルは CommonJS 形式なので、TypeScript 側で型を与える必要があります。
詳細な型が必要な場合は validate() を呼び出す関数を外側に用意しましょう。
//自動生成された名状しがたいコード
declare const validate: (data: unknown) => boolean;
export = validate;
// This file is auto-generated by the validateSchemaPlugin.
import validate from "./personValidate";
import type { Person } from "./types";
export const isPerson = (data: unknown): data is Person => {
return validate(data);
};
⚠️ Vitest など一部の環境では .cjs ファイルの import に失敗します。
その場合は require() を使用して回避してください。
import { describe, test, expect } from "vitest";
import { makeActorData } from "./actor";
const validate = require("./myValidate.cjs"); // Adjust the import based on your setup
4: JSON Schema を TypeScript 上で定義する
export const SCHEMA_PERSON = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer", minimum: 0 },
},
required: ["name", "age"],
additionalProperties: false,
} as const satisfies JSONSchemaType<{
name: string;
age: number;
}>;
satisfies により型の整合性を TypeScript 上でチェック可能になります。IDE 補完や型エラーも活用できるため強力です。
5: テストコードでJSONSchemaを検証する
JSON Schema の整合性は、次のように .json とコード内定義を toEqual() で比較することで確認できます。
import { test, expect } from "vitest";
import Ajv from "ajv"
import json from "./person.schema.json";
import { SCHEMA_PERSON } from "./schema";
import type {Person} from "./types"
test("SCHEMA_PERSON matches JSON schema", () => {
expect(SCHEMA_PERSON).toEqual(json);
});
test("validate",()=>{
const ajv = new Ajv()
const validate = ajv.compileSchema(SCHEMA_PERSON);
const person:Person = { name:"bob", age:20 };
})
終わりに
ajv の standaloneCode を用いた事前ビルド自体は専用の関数を呼び出すだけですが、形式・読み込み方法・テスト環境との兼ね合いでいくつかの落とし穴があります。
この記事が同様の構成に悩む方の参考になれば幸いです。