Arktype: runtime validation, discriminated unions with .merge()/.or(), spread keys. Use when mentioning arktype, type(), union types, command/event schemas.
Arktype: runtime validation, discriminated unions with .merge()/.or(), spread keys. Use when mentioning arktype, type(), union types, command/event schemas.
metadata
{"author":"epicenter","version":"1.0"}
Arktype Patterns
Patterns for composing arktype schemas, naming runtime schema values alongside inferred types, and building discriminated unions with .merge() and .or().
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.
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 calls rNode.distribute() to apply the merge to each branch individually (source).
The result is a union where each branch has all commandBase fields plus its variant-specific fields.
Arktype auto-detects the action key 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.
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.
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 branchconstResult = 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 because it breaks JSON Schema conversion.
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:
constAorB = 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.
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.
Let Schema Values and Inferred Types Share a Name
When an arktype schema exports both a runtime value and its inferred type with the same name, import that name once. TypeScript keeps value space and type space separate, so the same identifier can validate at runtime and annotate values at compile time.
Avoid aliasing the runtime schema just to make room for the type import.
// Bad: duplicates the name with an artificial Schema suffiximport {
WorkspaceIdasWorkspaceIdSchema,
typeWorkspaceId,
} from'./workspace-id';
constSession = type({
workspaceId: WorkspaceIdSchema,
});
Reach for an alias only when two imported values genuinely collide in the same namespace. A runtime schema and its inferred type do not collide.
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';
constRECORDING_MODES = ['manual', 'vad', 'upload'] asconst;
// 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:
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 Typeconst baseFields = { id: 'string', deviceId: DeviceId, createdAt: 'number' };
constCommand = 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.