home / skills / epicenterhq / epicenter / arktype
This skill helps you design robust discriminated unions in TypeScript using arktype's merge and or patterns for scalable commands and events.
npx playbooks add skill epicenterhq/epicenter --skill arktypeReview the files below or copy the command above to add this skill to your agents.
---
name: arktype
description: Arktype patterns for discriminated unions using .merge() and .or(), spread key syntax, and type composition. Use when building union types, combining base schemas with variants, or defining command/event schemas with arktype.
---
# Arktype Discriminated Unions
Patterns for composing discriminated unions with arktype's `.merge()` and `.or()` methods.
## When to Apply This Skill
- Defining a discriminated union schema (e.g., commands, events, actions)
- Composing a base type with per-variant fields
- Working with `defineTable()` schemas that use union types
## `base.merge(type.or(...))` Pattern (Recommended)
Use when you have shared base fields and per-variant payloads discriminated on a literal key. `.merge()` distributes over unions — it merges the base into each branch of the union automatically.
```typescript
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'),
},
),
);
```
### How it works
1. `type.or(...)` creates a union of plain object definitions — each is a variant with its own fields.
2. `commandBase.merge(union)` distributes the merge across each branch of the union. Internally, arktype calls `rNode.distribute()` to apply the merge to each branch individually ([source](https://github.com/arktypeio/arktype/blob/6d0639bf/ark/schema/roots/root.ts#L290-L302)).
3. The result is a union where each branch has all `commandBase` fields plus its variant-specific fields.
4. Arktype auto-detects the `action` key as a discriminant because each branch has a distinct literal value.
5. `switch (cmd.action)` in TypeScript narrows the full union — payload fields and result types are type-safe per branch.
### Why this pattern
| Property | Benefit |
| ---------------------- | -------------------------------------------------- |
| Base is a real `Type` | Reusable, composable, inspectable at runtime |
| `.merge()` distributes | No need to repeat `base.merge(...)` per variant |
| `type.or()` is flat | All variants in one list — easy to read and add to |
| Base appears once | DRY — change base fields in one place |
| Auto-discrimination | No manual discriminant config needed |
| Flat payload | No nested `payload` object — fields are top-level |
## `.merge().or()` Chaining Pattern (Good for 2-3 variants)
Use when you have a small number of variants where chaining reads naturally.
```typescript
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'),
}),
);
```
For 4+ variants, prefer `base.merge(type.or(...))` to avoid repeating `commandBase.merge(...)` per branch.
## The `"..."` Spread Key Pattern (Alternative)
Use when defining inline without a pre-declared base variable, or when you prefer a more compact syntax.
```typescript
const User = type({ isAdmin: 'false', name: 'string' });
const Admin = type({
'...': User,
isAdmin: 'true',
permissions: 'string[]',
});
```
The `"..."` key spreads all properties from the referenced type into the new object definition. Conflicting keys in the outer object override the spread type (same as `.merge()`).
### Spread key in unions
```typescript
const Command = type({
'...': commandBase,
action: "'closeTabs'",
tabIds: 'string[]',
}).or({
'...': commandBase,
action: "'openTab'",
url: 'string',
});
```
Functionally equivalent to `.merge().or()`. Choose based on readability preference.
## `.or()` Chaining vs `type.or()` Static
### Chaining (preferred for 2-3 variants)
```typescript
const Command = variantA.or(variantB).or(variantC);
```
### Static `type.or()` (preferred for 4+ variants)
```typescript
const Command = type.or(variantA, variantB, variantC, variantD, variantE);
```
The static form avoids deeply nested chaining and creates the union in a single call.
## `.merge()` Distribution Over Unions
`.merge()` distributes over unions on both sides. If you merge a union into an object type (or vice versa), the operation is applied to each branch individually:
```typescript
// 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' }))
```
**Constraint**: Each branch of the union must be an object type. If any branch is non-object (e.g., `'string'`), arktype will throw a `ParseError`:
```typescript
// ❌ 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' }));
```
## Optional Properties in Unions
Use arktype's `'key?'` syntax for optional properties. Never use `| undefined` for optionals — it breaks JSON Schema conversion.
```typescript
// 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
});
```
The `'result?': type({...}).or('undefined')` pattern is correct — the `?` makes the key optional, and `.or('undefined')` allows the value to be explicitly `undefined` when present. This is the standard pattern for "pending = absent, done = has value" semantics.
## Merge Behavior
- **Override**: When both the base and merge argument define the same key, the merge argument wins
- **Optional preservation**: If a key is optional (`'key?'`) in the base and required in the merge, the merge argument's optionality wins
- **No deep merge**: `.merge()` is shallow — it replaces top-level keys, not nested objects
- **Distributes over unions**: Both the base and the argument can be unions — merge is applied per-branch
## Discriminant Detection
Arktype auto-detects discriminants when union branches have distinct literal values on the same key:
```typescript
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
```
This works with any literal type — string literals, number literals, or boolean literals.
## Always Wrap Extracted Types with `type()`
When extracting reusable arktype types into named constants, always wrap them with `type()` — even for simple string literal unions. This ensures the value is a proper arktype `Type` with `.infer`, `.or()`, `.merge()`, etc.
```typescript
// 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'";
```
Both work when used as a value inside `type({...})` object literals (arktype coerces strings). But only the `type()`-wrapped version is a first-class `Type` that works in all positions.
## Anti-Patterns
### JS object spread (loses Type composition)
```typescript
// 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'",
});
```
This works but `baseFields` is not an arktype `Type` — you can't call `.merge()`, `.or()`, or inspect it at runtime. Prefer `.merge()` when the base should be a proper type.
### Repeating `base.merge(...)` per variant
```typescript
// 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' },
),
);
```
### Forgetting `'key?'` syntax for optionals
```typescript
// Bad: makes windowId required but accepting undefined
commandBase.merge({ windowId: 'string | undefined' });
// Good: makes windowId truly optional
commandBase.merge({ 'windowId?': 'string' });
```
## References
- `apps/tab-manager/src/lib/workspace.ts` — Commands table using `commandBase.merge(type.or(...))`
- `.agents/skills/typescript/SKILL.md` — Arktype optional properties section
- `.agents/skills/workspace-api/SKILL.md` — `defineTable()` accepts union types
- [arktype source: merge distributes](https://github.com/arktypeio/arktype/blob/6d0639bf/ark/schema/roots/root.ts#L290-L302) — `rNode.distribute()` in merge implementation
This skill documents practical patterns for building discriminated unions with Arktype using .merge(), .or(), spread key syntax, and type composition. It focuses on composing a reusable base Type with per-variant fields, clear discriminants, and JSON-schema-friendly optionals. Use these patterns when defining commands, events, or any variant-rich schemas in TypeScript.
Create a reusable base Type and extend it per variant via .merge() or the '...' spread key, then combine variants into a union with .or() or the static type.or() form. Arktype auto-detects a discriminant when each branch sets the same key to distinct literal values. Respect .merge() limitations: it only accepts object Types, so merge each variant individually and then union them.
Why use type.or() instead of chaining .or()?
type.or() is recommended for 5+ variants because it avoids deeply nested .or() chains and reads as a flat list of branches.
Can I pass a union into .merge()?
No. .merge() only accepts object Types. Merge each variant separately, then combine them with type.or().
How do I represent an optional value that can be explicitly undefined?
Use the 'key?' optional-key syntax and, if the value can be undefined when present, combine with .or('undefined') for the value type.