关于 arktype
这项开发技能介绍 arktype Schema 与推断类型的组合方式,重点涵盖可复用基础结构和判别联合,并说明可选字段及 workspace 字段表达式的限制。
主要功能
- 01用 merge 和 or 组合 Schema
- 02构建判别联合
- 03处理可选属性
- 04从 const 数组派生联合类型
- 05应用 workspace 字段限制
适用场景
- 01为命令建模并复用公共字段
- 02创建类型安全的变体负载
- 03使 Schema 枚举与常量保持一致
- 04校验 workspace 字段表达式
安装
复制下面的命令,在终端中运行。
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() 是扁平的 | 所有变体都在一个列表中,便于阅读和扩展 |
| 基础类型只出现一次 | DRY:在一个地方修改基础字段 |
| 自动判别 | 无需手动配置判别字段 |
| 扁平负载 | 没有嵌套的 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 个或更多变体,建议使用 base.merge(type.or(...)),以避免针对每个分支重复调用 commandBase.merge(...)。
"..." 展开键模式(替代方案)
当你想直接内联定义而不预先声明基础变量,或者更喜欢精简语法时使用。
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);
静态写法可避免深层嵌套的链式调用,并在一次调用中创建联合。
.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?' 语法表示可选属性。不要使用 | undefined 表示可选项,因为这会导致 JSON Schema 转换出错。
// 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() 包装,即使是简单的字符串字面量联合也不例外。这样可以确保该值是一个正规的 arktype Type,支持 .infer、.or()、.merge() 等操作。
// 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,
});
只有当两个导入的值确实在同一命名空间中冲突时,才使用别名。运行时架构与其推断类型不会冲突。
type.enumerated():从常量数组派生联合类型
使用 type.enumerated() 从现有的 as const 数组创建字符串字面量联合类型。这样可以自动让工作区架构与应用常量保持同步。
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
);
从常量派生的原因
- 单一事实来源:模型列表只在一个地方维护:常量数组
- 自动同步:向数组中添加模型会自动更新工作区架构
- 避免字符串偏差:架构不可能列出应用中不存在的模型
工作区字段表达式
工作区是纯 JSON 格式的 arktype 表达式字符串,因此有两种 arktype 特性无法在其中使用,而且它们导致的失败看起来毫不相关。
字段可以为 null,但不能是可选的。 工作区解析器会拒绝任何以
? 结尾的字段键(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()