arktype

Arktype Discriminated Unions

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "arktype" with this command: npx skills add epicenterhq/epicenter/epicenterhq-epicenter-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 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.

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 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 — rNode.distribute() in merge implementation

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

svelte

No summary provided by upstream source.

Repository SourceNeeds Review
General

documentation

No summary provided by upstream source.

Repository SourceNeeds Review
General

writing-voice

No summary provided by upstream source.

Repository SourceNeeds Review