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 arktype

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
9.6 KB
---
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

Overview

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.

How this skill works

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.

When to use it

  • Defining command, event, or action schemas with distinct variants
  • Composing a shared base Type with variant-specific fields
  • When you need runtime-inspectable and composable Types
  • When you want TypeScript-safe narrowing via a literal discriminant
  • Building schemas that will convert reliably to JSON Schema

Best practices

  • Prefer commandBase.merge({...}) for per-variant fields so the base remains a true arktype Type
  • For 5+ variants prefer type.or(variantA, variantB, ...) to avoid deeply nested chaining
  • For 2–4 variants chaining .or() on merged branches is readable and fine
  • Use 'key?' syntax for optional properties; avoid | undefined on required keys
  • Remember .merge() is shallow: top-level keys are overwritten by merge arguments

Example use cases

  • Command schemas for a background worker where each action has its own payload and optional result
  • Event streams where base metadata (id, createdAt, deviceId) is shared across event types
  • Define table schemas with union value types for use with defineTable()
  • API request/response shapes where each route uses a discriminant literal
  • Local-first app message passing between UI and worker using explicit variants

FAQ

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.