Zenn
🚅

Zodの次世代版?超高速なArkTypeを触ってみた

2025/03/15に公開
18

先日、以下の投稿を見かけました。

公式の投稿によると、「とりあえずArkTypeを使っとけ」とのことです。挙げられているデータにも、zodの約50倍高速に動くことが示されています。今回は速度についての検証は割愛し、機能面を中心に紹介します。

以下の公式マニュアルを参考に、主要な機能を独断と偏見で紹介していきます。ここに掲載されていない機能も多数存在するため、必要に応じて公式ドキュメントも参照してください。

https://arktype.io/

環境セットアップ

Dockerを用いてNode.jsの環境を構築し、検証を行います。

docker run -it --name node node:bullseye

TypeScriptは>=5.1である必要があります。package.jsonには"type": "module"[1]を指定します。
tsconfig.jsonには、以下のコメントアウトされた部分の指定が必須または推奨されます。他の部分は開発環境に合わせて設定してください。

tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strictNullChecks": true, // 必須
    "skipLibCheck": true, // 強く推奨
    "exactOptionalPropertyTypes": true, // 推奨
    "rootDir": "./",
    "outDir": "dist",
  },
  "include": ["src"]
}

オートコンプリートをフル活用するためにはVSCode側にも設定が必要です。以下の2つの項目を設定します。

// ArkTypeの "string | num" というような記述でオートコンプリートが有効になる
"editor.quickSuggestions": {
  "strings": "on"
},
// ArkTypeの型 "type" を優先的に自動importする
"typescript.preferences.autoImportSpecifierExcludeRegexes": [
  "^(node:)?os$"
]

これにより、以下のようなオートコンプリートが使えるようになります。


numberに続くkeywordsの候補を挙げてくれます

基本的な部分に触れてみる

型の定義からパースまで

実際に型を定義し、利用してみます。さまざまな要素を組み込むため、やや複雑な定義になっています。

import { type } from 'arktype';

// 画像ファイル
const imageURL = type({
  url: 'string.url', // URL形式の文字列
  alt: 'string < 280', // 280文字未満
});

// ユーザ情報
const user = type({
  userId: 'string.digits', // 数字だけの文字列
  email: 'string.email & /@gmail\\.com$/', // ドメインを限定したメールアドレス
  userName: 'string',
});

// 投稿
const tweet = type({
  '...': user, // スプレッド構文でuserのプロパティを展開
  id: 'string.uuid.v7',
  thumbnails: imageURL.array().default(() => []), // imageURLの配列
  text: 'string <= 140 = ""', // 140字以下 デフォルト値はこのようにも書ける
  likes: 'number.integer >= 0', // 0以上の整数
  retweets: 'number.integer >= 0', // 0以上の整数
  postDate: type('string').pipe(
    (str) => new Date(str),
    type(`Date <= ${Date.now()}`),
  ), // 現在時刻以前の日時をDate型に変換
  'even?': 'number.integer % 2', // おまけ:偶数のみ
});

// schemaから型を生成する
type Tweet = typeof tweet.infer;

// 簡単のためにobjectで宣言
const data = {
  userId: '12345',
  email: 'sample@gmail.com',
  userName: 'sample',
  id: '0195963a-44d1-7ae6-a9f1-d216731c6c5e',
  thumbnails: [],
  text: 'Hello world!',
  likes: 100,
  retweets: 50,
  postDate: '2025-01-01 00:00:00.000',
};

// objectを文字列化する
const jsonData = JSON.stringify(data);
// 文字列を受け取って`JSON.parse`を通すパイプ
const parseJson = type('string').pipe((str) => JSON.parse(str));

// 2つのデータをパースする
// この時点でエラーは捕捉していない
const output = tweet(data); // objectを直接
const outputJson = tweet(parseJson(jsonData));

// パースエラーをキャッチする
if (output instanceof type.errors) {
  console.error(output.summary);
  output.throw(); // 例外はこれでthrowできる
} else {
  console.log('オブジェクトの構造は意図通りです!');
}

if (outputJson instanceof type.errors) {
  console.error(outputJson.summary);
} else {
  console.log('JSONの構造も意図通りです!');
}

// スキーマ構造を自然言語で出力してくれる
console.log(tweet.description);

上記コードを実行すると、以下のように出力され、正常に動作していることが確認できます。

オブジェクトの構造は意図通りです!
JSONの構造も意図通りです!

また、tweet.descriptionでは以下のような文字列が得られます。見やすいように整形しています。

{
  email: an email address and matched by @gmail\.com$,
  id: a UUIDv7,
  likes: an integer and non-negative,
  postDate: a morph from a string to a Date and 4:43:11.813 AM, March 16, 2025 or earlier,
  retweets: an integer and non-negative,
  userId: only digits 0-9,
  userName: a string,
  text?: a string and at most length 140,
  thumbnails?: {
    alt: string <= 279,
    url: string
  }[],
  even?: even
}

エラーハンドリング

次に、意図的に定義に違反するデータを入力してみます。
output.throw()のように例外を投げると、以下のようなスタックトレースが出力されます。

TraversalError: 
  • email must be matched by @gmail\.com$ (was "sample@gmail.co.jp")
  • likes must be non-negative (was -100)
    at ArkErrors.toTraversalError (file:///home/node/arktype/node_modules/@ark/schema/out/shared/errors.js:120:16)
    at ArkErrors.throw (file:///home/node/arktype/node_modules/@ark/schema/out/shared/errors.js:113:20)
    at file:///home/node/arktype/dist/src/index.js:47:17
    at ModuleJob.run (node:internal/modules/esm/module_job:274:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:98:5)

エラーの原因が明確に示されるため、非常に分かりやすいです。
output.summaryを確認すると、以下のようになっています。

email must be matched by @gmail\.com$ (was "sample@gmail.co.jp")
likes must be non-negative (was -100)

こちらは、エラーに関する情報のみが表示されており、デバッグに非常に役立ちそうです。

応用的な使い方

いくつか応用的な書き方を紹介します。

事前定義の型でパースまで済ませる

前章ではJSONの文字列をtweetで検証するときには、ひとまずparseJsonというパイプを用意して中でJSON.parseを適用していました。
しかし、ArkTypeではこれを事前定義の型[2]を用いてパースまで行うことができます。

// 先ほどの`data`と`tweet`は使いまわします

// objectを文字列化してもう一回パースしてみる
const jsonData = JSON.stringify(data);
// JSONの文字列をパース、型の検証まで行います
const parseJson2Tweet = type('string.json.parse').to(tweet);

// 実際にパースする
const outputJson = parseJson2Tweet(jsonData);

type("string.json.parse")だけでtype("string").pipe((str) => JSON.parse(str))と同じ処理を行うことができるため、全体的な流れもスマートになりました。

同様な処理ができる事前定義の型として、以下のようなものが文字列には用意されています。いずれもフォーマットに適した文字列を受け取り、パースを行ってくれます。

  • string.date.epoch.parse
  • string.date.iso.parse
  • string.date.parse
  • string.integer.parse
  • string.json.parse
  • string.numeric.parse
  • string.url.parse

つまり、前章で紹介したコード中の日時データのやり取りについても、以下のように書くことができますね。

// 投稿
const tweet = type({
  ..., // (中略)
  postDate: type('string.date.parse').to(type(`Date <= ${Date.now()}`)), // 現在時刻以前の日付をDate型に変換
  ..., // (中略)
});

このようなkeywordsの一覧は以下のページで確認できます。

https://arktype.io/docs/keywords

定義していないプロパティの扱い方

定義外のプロパティを受け取ったときの挙動を明示的に定義することができます。

// ユーザ情報
const user = type({
  userId: 'string.digits', // 数字だけの文字列
  email: 'string.email & /@gmail\\.com$/', // ドメインを限定したメールアドレス
  userName: 'string',
});

const userData = {
  userId: '123456',
  email: 'sample@gmail.com',
  userName: 'sample-user',
  followers: 1000,
  following: 1000,
};

const outputUser = user(userData);

上記のコードは特にエラーもなく動き、これがデフォルト挙動です。ただ、未定義のfollowersfollowingの2つのプロパティについては、特に検証されることなく出力のoutputUserにも含まれています。

この余分なプロパティを拒否してパース時にエラーとするには、以下のようにします。

// ユーザ情報
const user = type({
  '+': 'reject', // 余計なプロパティは拒否する
  userId: 'string.digits', // 数字だけの文字列
  email: 'string.email & /@gmail\\.com$/', // ドメインを限定したメールアドレス
  userName: 'string',
});

これによって以下のようにパース時に不要なプロパティを捕捉することができます。

followers must be removed
following must be removed

また、'reject'の他にも'delete''ignore'も選択することができ、それぞれ不要なプロパティを削除、無視(デフォルト挙動)します。

可変長なタプルの定義

例えば以下のようにすると、'...'は直後の可変長の型と見なすようになります。タプルの定義中に高々1回使うことができます。

const variadicTuple = type(['string', '...', 'number[]']);
const data = variadicTuple(['hello world!', 57, 8, 20]);

絞り込み式を使ってさらに柔軟な制約を課す

narrow expressionsを使うことで、さらに柔軟な制約やカスタムしたエラーを出すことができます。

例えば、以下は公式マニュアルのコードをカスタムしたものですが、ユーザ登録フォームのパスワードと確認用パスワードが一致するよう、narrowで繋げたメソッドの中で検証しています。ctx.rejectを使うことでバリデーションの失敗を定義でき、各プロパティで指定した内容がいい感じに出力されます。

// ユーザ登録フォーム
const form = type({
  firstName: 'string',
  lastName: 'string',
  birthday: type('string.date.parse').to(`Date < ${Date.now()}`),
  email: 'string.email',
  password: 'string > 6',
  confirmPassword: 'string > 6',
}).narrow((data, ctx) => {
  if (data.password === data.confirmPassword) {
    return true; // trueを返すと成功
  }
  // rejectを返すと失敗
  return ctx.reject({
    expected: 'identical to password!!',
    actual: '', // セキュリティの都合上、実際の値は表示しない
    path: ['confirmPassword'],
  });
});

const output = form({
  firstName: 'John',
  lastName: 'Smith',
  birthday: '1990-01-01',
  email: 'sample@gmail.com',
  password: 'password1',
  confirmPassword: 'password', // passwordと一致しない
});

if (output instanceof type.errors) {
  console.log(output.summary);
} else {
  console.log(JSON.stringify(output, null, 2));
}

以上の例では、パスワードが一致しないため、以下のようなメッセージになります。

confirmPassword must be identical to password!!
// "[path] must be [expected]!! (was [actual])" というフォーマットになる

オブジェクトから一部の型定義を抽出する

TypeScriptでは以下のように直接型を辞書のように呼び出して型定義を抽出することができます。

type User = {
  userId: number;
  name: string;
};
type UserName = User['name']; // string

ArkTypeではgetメソッドを使って同じことをすることができます。ネストされている場合には.get('path', 'to', 'property')のように書くこともできます。

// 先ほどの`user`を流用

const id = user.get('userId');

matchを使って型安全なswitchを導入

今まではtype関数を中心に見てきましたが、どうやら最近match関数というものが導入されたようです。
この関数は型安全なswitchのような振る舞いをし、入力された値に対してそれぞれに処理を分岐させることができます。

以下の例では冗長ですが、各種型に応じて文字列化する処理を適用しています。

import { match } from 'arktype';

const stringify = match({
  string: (v) => v,
  number: (v) => String(v),
  boolean: (v) => (v ? 'true' : 'false'),
  null: () => 'null',
  undefined: () => 'undefined',
  bigint: (v) => `${v}n`,
  default: 'assert',
});

最後のdefaultについては通常のswitch文と同じで、どこにも適合しなかった場合の処理を記述します。他の分岐と同じく関数を渡すこともできますし、以下のような選択肢もあります。

  • assert: マッチしなかった場合にエラーを即時投げる
  • never: 型推論を用いた入力を受け付け、どれにもマッチしなかった場合にエラーを投げる
    • (捕捉) assertとは違い、静的な部分でエラーが出るようになります。つまり、どれにもマッチしないものを渡すと、型推論の結果を見てトランスパイルが通らなくなります。
  • reject: マッチしなかった場合エラーオブジェクトを返す

相性の良いパッケージ

公式マニュアルではtRPCreact-hook-formhonoの3つについて組み合わせ例を紹介しています。これらのパッケージを利用する際には、特に利用を検討してみても良いかもしれません。

https://arktype.io/docs/integrations

さいごに

国内ではまだ記事が少ないArkTypeを試してみました。柔軟に構造やデータの制約を定義でき、非常に便利そうです。
zodとは異なるアプローチや発想で型検証を行うのでどちらにも良いところはあると思いますが、特にエラーメッセージの可読性についてはArkTypeの方が上だと感じました。
一方で、例外を捕捉する部分はinstanceofを使うのがやや煩雑だと感じました。

公式マニュアルには、他にも様々な設定やAPIが掲載されています。ぜひそちらの方も見てみて、ArkTypeを活用してみてください!

ダウンロード数は右肩上がりなので、今後の発展に期待しています。

https://www.npmjs.com/package/arktype

脚注
  1. あるいはESM importが利用可能な環境 ↩︎

  2. なお、この事前定義の型のことは公式的には"keywords"というらしいです。 ↩︎

18

Discussion

ログインするとコメントできます