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.
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
type.or(...)creates a union of plain object definitions — each is a variant with its own fields.commandBase.merge(union)distributes the merge across each branch of the union. Internally, arktype callsrNode.distribute()to apply the merge to each branch individually (source).- The result is a union where each branch has all
commandBasefields plus its variant-specific fields. - Arktype auto-detects the
actionkey as a discriminant because each branch has a distinct literal value. 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.
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.
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()).
Constraint: The "..." key must be the first key in the object. Arktype throws ParseError: Spread operator may only be used as the first key otherwise. Prefer .merge() when you need more flexibility.
Spread key in unions
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)
const Command = variantA.or(variantB).or(variantC);
Static type.or() (preferred for 4+ variants)
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:
// 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:
// ❌ 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.
// 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:
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.
// 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.
type.enumerated() — Derive Unions from Const Arrays
Use type.enumerated() to create string literal unions from existing as const arrays. This keeps the workspace schema in sync with app constants automatically.
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'")
Extracting from rich object arrays
When constants are objects with a name or id field, map first:
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),
);
In discriminated unions
Combine with base.merge(type.or(...)) to build unions where each variant's model field derives from its constant array:
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
);
Why derive from constants
- Single source of truth: Model lists are maintained in one place — the constant arrays
- Auto-sync: Adding a model to the array automatically updates the workspace schema
- No string drift: Impossible for the schema to list models that don't exist in the app
Anti-Patterns
JS object spread (loses Type composition)
// 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
// 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
// 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 usingcommandBase.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 —
rNode.distribute()in merge implementation