超直感的なバリデーション?ArkType入門

1

こんにちは、フォルシア株式会社エンジニアの籏野です。

今回はzodvalibotと同じバリデーション用ライブラリであるArkTypeについて紹介したいと思います。

ArkTypeの特徴

ArkTypeの大きな特徴としては以下の点が挙げられます。

  • TypeScriptの型記法に似た記法によりバリデーション定義が可能
  • 実行時にはzodの100倍高速に動作し、エディター上でも高パフォーマンスな補完を提供する
  • Standard Schemaへの対応
    • ※Standard Schemaはzodやvalibotといったバリデーションライブラリの共通インターフェース仕様です。

本記事では主にArkTypeの利用方法について紹介していきます。

セットアップ

筆者の環境は以下の通りです。

  • Node.js v22.14.0
  • pnpm 10.5.2

ArkTypeを利用する場合は以下の通り、TypeScriptの設定が必要です。

  • TypeScriptのバージョンは5.1以上
  • strict もしくは strictNullChecks を有効にする
  • exactOptionalPropertyTypesを有効にすることを推奨

またVSCodeを利用している場合、以下のように設定をしておくとよいようです。

// allow autocomplete for ArkType expressions like "string | num"
"editor.quickSuggestions": {
	"strings": "on"
},
// prioritize ArkType's "type" for autoimports
"typescript.preferences.autoImportSpecifierExcludeRegexes": [
	"^(node:)?os$"
],

またVSCodeユーザーはArkTypeの拡張機能を入れておくと、シンタックスハイライト/エラー表示等がサポートされるのでおすすめです。

上記用意したうえで、以下のコマンドでArkTypeをインストールします。

pnpm install arktype

記法

ArkTypeでは、最初に説明した通りTypeScriptの型記法に似た記法でバリデーション定義が可能です。
例えば、nameというプロパティでstring型を持つuserオブジェクトを定義する場合は以下のように記述します。

import { type } from "arktype";

const user = type({
	name: "string",
});

// バリデーションの実行
const result = user({...});

// 型定義は以下のようになる
// type User ={
// 	name: string;
// }
type User = typeof user.infer;

バリデーション定義userとそこから生成される型Userを見比べてみると一目瞭然ですが、どちらもnameというプロパティをstringで表現しています。
また、string もしくは numberのどちらかを満たす値のバリデーション定義にはユニオン型で用いられるパイプ記号(|)を用いることで以下の用に記述することができ、「TypeScriptの型記法に似た記法」というのがよくわかるかと思います。

const user = type({
	name: "string",
	version: "string | number", // -> string | number
});

なお、ArkTypeではプリミティブな値のバリデーション定義として以下をサポートしており、これらを組み合わせて定義を作っていくことが基本となります。

その他にも以下のように様々なTypeScriptでの記法を利用することができます。

// リテラル型
type("'android' | 'ios' | 1 | true"); // -> 'android' | 'ios' | 1 | true

// 配列
type("string[]"); // -> string[]
type("(string | number)[]"); // -> (string | number)[]

// オブジェクト
type({
	// 必須の項目
	required: "string",
	// 任意の項目
	"optional?": "string",
})

// スプレッド構文
const user = type({ isAdmin: "false", name: "string" })
const admin = type({
	"...": user,
	isAdmin: "true",
	permissions: "string[]"
}) // -> { isAdmin: true; permissions: string[]; name: string; }

TypeScriptの記法を超えたバリデーション定義

フォーム定義でよく使われる「最大10文字まで」や「メールアドレスであること」といった様々な制約条件は、TypeScriptの型記法だけで表現するのはなかなか難しく、そのような定義をArkTypeはどのように定義しているのでしょうか?

制約条件を"キーワード"で指定する

ArkTypeにおいて「メールアドレスであること」のような制約条件は、先に紹介したプリミティブな値に対するバリデーション定義に対して、.{{ 制約条件 }}というような形でキーワードを用いて指定することができます。
以下にその一部を紹介しますが、他にも様々な制約条件が用意されているので、ぜひ公式ドキュメントも合わせて参照してみてください。

// string
type("string.email"); // -> メールアドレスであること
type("string.alphanumeric"); // -> 英数字のみであること

// number
type("number.integer"); // -> 整数であること

制約条件を"演算子"で指定する

「10文字以上」「5以上」「2の倍数」といった制約条件は、「<, <=, >=, >, %」といったTypeScriptでも用いる演算子を利用して指定することができます。
string型とnumber型で意味が微妙に異なる点は少し注意が必要かもしれません。

// string
type("string >= 10"); // -> 10文字以上であること
+type("0 < string <= 10"); // -> 0文字より大きく10文字以下であること

// number
type("number > 5"); // -> 5より大きいこと
type("number % 2"); // -> 2の倍数であること

より複雑な制約条件の定義

では、上記のような記法では表現しきれないさらに複雑なバリデーション定義はどのように表現するのでしょうか?
単純に独自のバリデーション方法を導入したい場合は、narrowメソッドを用いることで定義できます。

// 奇数であることを表すバリデーション定義
const odd = type("number").narrow((n, ctx) =>
	// if even, add a customizable error and return false
	n % 2 === 0 ? ctx.mustBe("odd") : true
);

const out = odd(2);

if (out instanceof type.errors) {
	// hover summary to see validation errors
	console.error(out.summary); // -> must be odd (was 2)
} else {
	console.log(out); // -> 1
}

他にもpipeメソッドを用いることによりバリデーション結果を変換->再度バリデーションを行うこともできます。

const parseJson = type("string").pipe.try(
	(s): object => JSON.parse(s),
	type({
		name: "string",
		version: "string.semver"
	})
);

またJSON.parseについては、ArkTypeがstring.json.parseという定義を用意しているため、これを用いると単純にバリデーション定義同士を連結すればよいことになります。
その場合はtoメソッドを用いることで連結ができます。

const parseJson = type("string.json.parse").to({
	name: "string",
	version: "string.semver"
});

その他気になった機能の紹介

その他バリデーションライブラリを扱う上で気になりそうな点をいくつかまとめていきます。

デフォルト値の定義

APIの型定義などでは値が未指定の場合のデフォルト値の定義もしたいことがあります。
ArkTypeではオブジェクトかそれ以外かでデフォルト値の書き方が異なります。

// オブジェクトの場合、 = {{ デフォルト値 }}で指定する
const defaultableObject =  type({
	defaultableKey: "boolean = false"
});
defaultableObject({}); // -> { defaultableKey: false }

// それ以外の場合は、pipeメソッドを用いる
const defaultablePrimitive = type("string | undefined").pipe(v => v ?? "default");
defaultablePrimitive(undefined); // -> "default"

余分なプロパティに対する挙動

例えばzodの場合、あるオブジェクトのバリデーション定義に対して余分なプロパティがある場合はその値を削除します。
一方、ArkTypeでは余分なプロパティに対しては何もせず検証を通過させます。

const target = {
	name: "John",
	extraProperty: "extra"
}

const zodDefinition = z.object({
	name: z.string()
});
const arkTypeDefinition = type({
	name: "string"
});

zodDefinition.parse(target); // -> { name: "John" }
arkTypeDefinition(target); // -> { name: "John", extraProperty: "extra" }

ArkTypeでこの挙動を変更したい場合には、オブジェクトのバリデーション定義に+プロパティを追加します。

const arkTypeDefinition = type({
	name: "string",
	// reject: 余計なプロパティがある場合はエラーとする
	// delete: 余計なプロパティを削除する
	// ignore: 余計なプロパティを無視する(デフォルト)
	"+": "reject" 
});

また、上記のような挙動をグローバルに設定する方法も用意されていますので、興味のある方は参照してみてください。

パターンマッチング

ArkTypeでは上記のようなTypeScriptライクな記法を用いてパターンマッチングを行う関数も提供しているので紹介します。

import { match } from "arktype";

const sizeOf = match({
	"string | Array": (v) => v.length,
	number: (v) => v,
	bigint: (v) => v,
	default: "never",
});

defaultはどのパターンにもマッチングしなかった時に実行される分岐であり、以下のように生成される関数の入力の型と実行時の挙動が異なります。

入力の型 実行時の挙動
assert unknown型 例外を投げる
never default以外の指定値から推論 例外を投げる
reject unknown型 例外は投げず、ArkErrorを返す
(data: In) => unknown unknown 関数を実行

最後に

ArkTypeいかがでしたでしょうか?
TypeScriptと似た記法という点は確かに学習コストという点で良いかもしれません。
ArkTypeには他にも紹介しきれなかった様々なメソッドがありますので、本記事で興味が出た方はぜひ試してみてください!

この記事を書いた人

籏野 拓
2018年新卒入社

1
FORCIA Tech Blog

Discussion