arktype について
arktype のスキーマと推論型を組み合わせる開発スキルで、再利用可能な基底型や判別共用体を中心に解説します。オプショナルプロパティや workspace フィールド式の制約も扱います。
主な機能
- 01merge と or によるスキーマ合成
- 02判別共用体の構築
- 03オプショナルプロパティの処理
- 04const 配列からの共用体生成
- 05workspace フィールド制約の適用
活用シーン
- 01共通フィールドを持つコマンドのモデル化
- 02型安全なバリアントペイロードの作成
- 03スキーマ列挙値と定数の同期
- 04workspace フィールド式の検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ epicenterhq/ epicenter --skill arktypeインストール後、Claude Code で/arktypeを入力して読み込まれたか確認します。
詳細
Arktype パターン
arktype スキーマを合成し、推論された型と並べて実行時スキーマ値に名前を付け、.merge() と .or() で判別可能なユニオンを構築するパターンです。
base.merge(type.or(...)) パターン(推奨)
共通のベースフィールドと、リテラルキーで判別されるバリアントごとのペイロードがある場合に使用します。.merge() はユニオンに対して分配され、ベースをユニオンの各分岐に自動的にマージします。
import { type } from 'arktype';
const commandBase = type({
id: 'string',
deviceId: DeviceId,
createdAt: 'number',
_v: '1',
});
const Command = commandBase.merge(
type.or(
{
action: "'closeTabs'",
tabIds: 'string[]',
'result?': type({ closedCount: 'number' }).or('undefined'),
},
{
action: "'openTab'",
url: 'string',
'windowId?': 'string',
'result?': type({ tabId: 'string' }).or('undefined'),
},
{
action: "'activateTab'",
tabId: 'string',
'result?': type({ activated: 'boolean' }).or('undefined'),
},
),
);
仕組み
type.or(...)は、プレーンなオブジェクト定義のユニオンを作成します。それぞれが独自のフィールドを持つバリアントです。commandBase.merge(union)は、ユニオンの各分岐に対してマージを分配します。内部では、arktype がrNode.distribute()を呼び出して、各分岐に個別に適用します(ソース)。- 結果は、各分岐がすべての
commandBaseフィールドと、そのバリアント固有のフィールドを持つユニオンです。 - 各分岐で異なるリテラル値を持つため、arktype は
actionキーを判別子として自動検出します。 - TypeScript の
switch (cmd.action)は、ユニオン全体を絞り込みます。ペイロードフィールドと結果の型は、各分岐に応じて型安全になります。
このパターンを使う理由
| 特性 | 利点 |
|---|---|
ベースが実際の Type | 再利用でき、合成可能で、実行時に検査できます |
.merge() が分配される | バリアントごとに base.merge(...) を繰り返す必要がありません |
type.or() がフラット | すべてのバリアントが1つのリストにまとまり、読みやすく追加も簡単です |
| ベースが1か所にある | DRY:ベースフィールドを1か所で変更できます |
| 自動判別 | 手動で判別子を設定する必要がありません |
| フラットなペイロード | ネストされた payload オブジェクトがなく、フィールドはトップレベルにあります |
.merge().or() のチェーンパターン(2~3個のバリアントに適しています)
バリアントの数が少なく、チェーンで記述すると自然に読める場合に使用します。
const Command = commandBase
.merge({
action: "'closeTabs'",
tabIds: 'string[]',
'result?': type({ closedCount: 'number' }).or('undefined'),
})
.or(
commandBase.merge({
action: "'openTab'",
url: 'string',
'result?': type({ tabId: 'string' }).or('undefined'),
}),
);
バリアントが4個以上の場合は、各分岐で commandBase.merge(...) を繰り返さずに済む base.merge(type.or(...)) を推奨します。
"..." スプレッドキーのパターン(代替案)
事前にベース変数を宣言せずにインラインで定義する場合や、より簡潔な構文を使いたい場合に使用します。
const User = type({ isAdmin: 'false', name: 'string' });
const Admin = type({
'...': User,
isAdmin: 'true',
permissions: 'string[]',
});
"..." キーは、参照先の型に含まれるすべてのプロパティを新しいオブジェクト定義に展開します。外側のオブジェクトでキーが競合する場合は、スプレッドされた型が上書きされます(.merge() と同じです)。
制約:"..." キーはオブジェクト内の最初のキーでなければなりません。そうでない場合、arktype は ParseError: Spread operator may only be used as the first key をスローします。より柔軟性が必要な場合は、.merge() を推奨します。
ユニオン内のスプレッドキー
const Command = type({
'...': commandBase,
action: "'closeTabs'",
tabIds: 'string[]',
}).or({
'...': commandBase,
action: "'openTab'",
url: 'string',
});
機能的には .merge().or() と同等です。読みやすさの好みに応じて選択してください。
.or() のチェーンと静的な type.or() の比較
チェーン(2~3個のバリアントに推奨)
const Command = variantA.or(variantB).or(variantC);
静的な type.or()(4個以上のバリアントに推奨)
const Command = type.or(variantA, variantB, variantC, variantD, variantE);
静的な形式では、深くネストしたチェーンを避け、1回の呼び出しでユニオンを作成できます。
ユニオンに対する .merge() の分配
.merge() は、両側にあるユニオンに対して分配されます。ユニオンをオブジェクト型にマージする場合(またはその逆)、操作は各分岐に個別に適用されます。
// base.merge(union): distributes merge across each branch
const Result = baseType.merge(type.or({ a: 'string' }, { b: 'number' }));
// Equivalent to: type.or(baseType.merge({ a: 'string' }), baseType.merge({ b: 'number' }))
制約:ユニオンの各分岐はオブジェクト型でなければなりません。分岐にオブジェクト以外の型(例:'string')が含まれている場合、arktype は ParseError をスローします。
// ❌ WRONG: 'string' is not an object type
commandBase.merge(type.or({ a: 'string' }, 'string'));
// ✅ CORRECT: all branches are object types
commandBase.merge(type.or({ a: 'string' }, { b: 'number' }));
ユニオン内のオプショナルプロパティ
オプショナルプロパティには、arktype の 'key?' 構文を使用します。JSON Schema への変換を妨げるため、オプショナルプロパティに | undefined は使用しないでください。
// Good: optional property syntax
commandBase.merge({
action: "'openTab'",
url: 'string',
'windowId?': 'string',
'result?': type({ tabId: 'string' }).or('undefined'),
});
// Bad: explicit undefined union on a required key
commandBase.merge({
action: "'openTab'",
url: 'string',
windowId: 'string | undefined', // Breaks JSON Schema
});
'result?': type({...}).or('undefined') パターンが正しい方法です。? はキーをオプショナルにし、.or('undefined') はキーが存在する場合に値として明示的に undefined を許可します。これは「保留中=なし、完了=値あり」というセマンティクスの標準的なパターンです。
マージの動作
- 上書き:ベースとマージ引数の両方で同じキーが定義されている場合、マージ引数が優先されます
- 任意指定の引き継ぎ:ベースでキーが任意(
'key?')で、マージ引数で必須の場合、マージ引数の任意指定が優先されます - ディープマージなし:
.merge()はシャローです。ネストされたオブジェクトではなく、トップレベルのキーを置き換えます - ユニオンに対して分配:ベースと引数はどちらもユニオンにできます。マージは各分岐に適用されます
判別子の検出
Arktype は、ユニオンの各分岐が同じキーに異なるリテラル値を持つ場合、判別子を自動検出します。
const AorB = type({ kind: "'A'", value: 'number' }).or({
kind: "'B'",
label: 'string',
});
// Arktype internally uses `kind` as the discriminant
// Validation checks `kind` first, then validates only the matching branch
これは、文字列リテラル、数値リテラル、ブール値リテラルなど、あらゆるリテラル型で機能します。
抽出した型は常に type() でラップする
再利用可能な arktype 型を名前付き定数に抽出するときは、単純な文字列リテラルのユニオンであっても、必ず type() でラップしてください。これにより、値が .infer、.or()、.merge() などを備えた適切な arktype Type になります。
// GOOD: wrapped with type(), composable, has .infer, works with .or()/.merge()
const tabGroupColor = type(
"'grey' | 'blue' | 'red' | 'yellow' | 'green' | 'pink' | 'purple' | 'cyan' | 'orange'",
);
const commandBase = type({
id: CommandId,
deviceId: DeviceId,
createdAt: 'number',
_v: '1',
});
// BAD: plain string, not a Type, can't compose, no .infer
const tabGroupColor =
"'grey' | 'blue' | 'red' | 'yellow' | 'green' | 'pink' | 'purple' | 'cyan' | 'orange'";
どちらも type({...}) のオブジェクトリテラル内で値として使用する場合は機能します(arktype は文字列を型に変換します)。ただし、すべての位置で使える第一級の Type になるのは、type() でラップしたものだけです。
スキーマ値と推論された型に同じ名前を付ける
arktype スキーマが実行時の値と推論された型の両方を同じ名前でエクスポートする場合は、その名前を一度だけインポートします。TypeScript では値の名前空間と型の名前空間が分かれているため、同じ識別子を実行時の検証とコンパイル時の値の型注釈に使用できます。
// Good: one import covers both namespaces
import { WorkspaceId } from './workspace-id';
const Session = type({
workspaceId: WorkspaceId,
});
type SessionResponse = {
workspaceId: WorkspaceId;
};
型のインポート用の名前を空けるためだけに、実行時スキーマに別名を付けるのは避けてください。
// Bad: duplicates the name with an artificial Schema suffix
import {
WorkspaceId as WorkspaceIdSchema,
type WorkspaceId,
} from './workspace-id';
const Session = type({
workspaceId: WorkspaceIdSchema,
});
別名を使うのは、同じ名前空間で2つのインポート値が実際に衝突する場合に限ってください。実行時スキーマと推論された型は衝突しません。
type.enumerated():定数配列からユニオンを導出する
既存の as const 配列から文字列リテラルのユニオンを作成するには、type.enumerated() を使用します。これにより、ワークスペーススキーマがアプリの定数と自動的に同期されます。
import { type } from 'arktype';
const RECORDING_MODES = ['manual', 'vad', 'upload'] as const;
// Spread the const array into type.enumerated()
const recordingMode = type.enumerated(...RECORDING_MODES);
// Equivalent to: type("'manual' | 'vad' | 'upload'")
オブジェクトの配列から抽出する
定数が name フィールドまたは id フィールドを持つオブジェクトの場合は、先にマッピングします。
const OPENAI_TRANSCRIPTION_MODELS = [
{ name: 'whisper-1', description: '...', cost: '$0.36/hour' },
{ name: 'gpt-4o-transcribe', description: '...', cost: '$0.36/hour' },
] as const;
const openaiModel = type.enumerated(
...OPENAI_TRANSCRIPTION_MODELS.map((m) => m.name),
);
判別共用体で使う
base.merge(type.or(...)) と組み合わせると、各バリアントのモデルフィールドを定数配列から導出するユニオンを作成できます。
const transcriptionConfig = type.or(
{ service: "'OpenAI'", model: type.enumerated(...OPENAI_MODELS.map((m) => m.name)) },
{ service: "'Groq'", model: type.enumerated(...GROQ_MODELS.map((m) => m.name)) },
{ service: "'whispercpp'" }, // local: no model field
);
定数から導出する理由
- 信頼できる情報源の一元化:モデルの一覧は定数配列の1か所で管理されます
- 自動同期:配列にモデルを追加すると、ワークスペーススキーマも自動的に更新されます
- 文字列の不整合を防止:アプリに存在しないモデルがスキーマに含まれることはありません
ワークスペースフィールドの式
ワークスペースは純粋なJSONの arktype 式文字列で構成されるため、arktype の機能のうち2つはワークスペース内では利用できません。また、それぞれの失敗に関連性はありません。
フィールドは nullable ですが、optional にはできません。 ワークスペースパーサーは、キーが ? で終わるフィールドを拒否します(packages/workspace/src/workspace.ts)。任意指定のマーカーはキーに含まれており、JSON の往復変換後もキーのマーカーが保持されなかったためです(ADR-0213)。
{ date: 'string|null = null' } // Good
{ 'date?': 'string' } // Refused: "declare it nullable instead"
配列にデフォルト値は設定できません。 'string[] = []' は解析時にエラーをスローするため(Expected an expression before '[]')、空の場合は null として表現してください。
{ tags: 'string[]|null = null' } // Good
{ tags: 'string[] = []' } // Throws
アンチパターン
JS のオブジェクトスプレッド(Type の合成が失われる)
// Bad: base is a plain object, not a Type
const baseFields = { id: 'string', deviceId: DeviceId, createdAt: 'number' };
const Command = type({ ...baseFields, action: "'closeTabs'" }).or({
...baseFields,
action: "'openTab'",
});
これは機能しますが、baseFields は arktype Type ではありません。.merge() や .or() の呼び出し、実行時の調査はできません。ベースを適切な型にする場合は、.merge() を使ってください。
バリアントごとに base.merge(...) を繰り返す
// Bad: repetitive, base.merge repeated for every variant
type.or(
commandBase.merge({ action: "'closeTabs'", tabIds: 'string[]' }),
commandBase.merge({ action: "'openTab'", url: 'string' }),
commandBase.merge({ action: "'activateTab'", tabId: 'string' }),
);
// Good: merge once, union the variants
commandBase.merge(
type.or(
{ action: "'closeTabs'", tabIds: 'string[]' },
{ action: "'openTab'", url: 'string' },
{ action: "'activateTab'", tabId: 'string' },
),
);
任意の項目に 'key?' 構文を忘れる
// Bad: makes windowId required but accepting undefined
commandBase.merge({ windowId: 'string | undefined' });
// Good: makes windowId truly optional
commandBase.merge({ 'windowId?': 'string' });
参考資料
.agents/skills/typescript/SKILL.md:Arktype の任意プロパティのセクション- arktype のソース:merge は分配する:merge の実装における
rNode.distribute()