Types

Branded primitives and JSON-safe contracts.

TypeScript does not stop you from passing a raw number where a document position is expected, even though a node child index and a UTF-16 offset are not interchangeable. One is indexed in children; the other in code units. Mixing them compiles and fails only at runtime. @spine-editor/core/types closes that gap with branded primitives.

Worked example

import { pos, range, type Pos, type Range } from "@spine-editor/core";

const p: Pos = pos(12);
const r: Range = range(p, pos(18));

function deleteRange(target: Range): void {
  dispatch({ kind: "delete", selection: { kind: "text", ...target } });
}

deleteRange(r);

pos(value) rejects non-integer or negative inputs. range(left, right) normalizes ordering — range(pos(18), pos(12)) returns { from: pos(12), to: pos(18) }.

Raw numbers can be forced through with a cast, but that bypasses the runtime validation that makes positions trustworthy.

Branded scalars

type Brand<T, B extends string> = T & { readonly __brand: B };

type Pos            = Brand<number, "Pos">;
type NodeTypeName   = Brand<string, "NodeTypeName">;
type MarkTypeName   = Brand<string, "MarkTypeName">;
type PluginId       = Brand<string, "PluginId">;
type CommandId      = Brand<string, "CommandId">;
type CapabilityId   = Brand<string, "CapabilityId">;

Branding is a compile-time fence only; the runtime representation is the unbranded primitive. The brand prevents accidental assignment across domains, not forged values.

Positions and ranges

  • Pos — zero-based UTF-16 code-unit offset into the document. Constructor: pos(value).
  • Range — half-open { from: Pos; to: Pos } with from <= to. Constructor: range(left, right).
  • Assoc-1 | 1. At a change boundary, a position can land on the left or right side of the change; Assoc picks which.

Paths

NodePath = readonly number[]. The empty array is the root. Paths index children, never text offsets. [0, 2] is the third child of the first child of the root.

JSON-safe values

type JsonValue =
  | null
  | boolean
  | number
  | string
  | readonly JsonValue[]
  | { readonly [key: string]: JsonValue };

type Attrs = Readonly<Record<string, JsonValue>>;

Every durable artifact in the engine — intents, transaction JSON, trace events, schema JSON — bottoms out in JsonValue. No Date, no Map, no class instances.

Exhaustiveness

assertNever(value: never, message?: string): never;

Used in every discriminated-union switch (intents, steps, selections, trace events) so a new variant is a compile error at every consumer, not a silent fallthrough at runtime.