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 }withfrom <= 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;Assocpicks 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.