Skip to content

@xmachines/play

API / @xmachines/play

Core protocol layer for the Universal Player Architecture. It defines PlayEvent, PlayError, and the contracts that keep the business logic loosely coupled to the runtime adapters.

License: MIT Version


Installation

Terminal window
pnpm add @xmachines/play

This package requires Node.js >= 24.0.0. Every package is an ES module ("type": "module").


Overview

@xmachines/play is the base package of the XMachines ecosystem. It defines the smallest set of types and utilities that every other @xmachines/* package builds on:

  • PlayEvent<TPayload> — the universal event contract for Actor ↔ Infrastructure communication
  • PlayError — the typed base class for all @xmachines/* runtime errors
  • NonNullableError — the error that a package throws when a required value is null or undefined
  • assertNonNullable() — the assertion utility that narrows T | null | undefined to T
  • Cleanup, asCleanup(), DISPOSE — the release protocol that every subscription of the ecosystem hands back

These protocols implement the architectural invariants that the Play RFC defines:

#InvariantDescription
INV-01Actor AuthorityThe Actor is the final authority. Guards decide all transitions
INV-02Strict SeparationThe business logic never imports a UI framework or a routing library
INV-04Passive InfrastructureThe infrastructure observes the Actor signals. It never enforces a guard
INV-05Signal-Only ReactivityTC39 Signals are the only medium that crosses a boundary

Usage

PlayEvent<TPayload>

The minimal event contract is any object with a type: string property. The contract is framework-agnostic. It works with XState, with Robot, and with every other state machine library.

import type { PlayEvent } from "@xmachines/play";
// Flexible (accepts any additional fields):
const event: PlayEvent = { type: "auth.login", userId: "user123" };
// Type-safe (with generic payload):
type LoginEvent = PlayEvent<{ userId: string; timestamp: number }>;
const loginEvent: LoginEvent = {
type: "auth.login",
userId: "user123",
timestamp: Date.now(),
};
// TypeScript error: missing required field
const invalid: LoginEvent = { type: "auth.login" }; // Error!

PlayError

Base class for every @xmachines/* runtime error. Each error has a stable scope (the class or the module that throws it) and a stable code (a machine-readable identifier). Always branch on .code or on the subclass. Never branch on .message.

import { assertNonNullable } from "@xmachines/play";
import { NonNullableError, PlayError } from "@xmachines/play/errors";
try {
assertNonNullable(document.getElementById("app"), "#app");
} catch (err) {
if (err instanceof NonNullableError) {
// err.scope === "assertNonNullable"
// err.code === "PLAY_NON_NULLABLE"
console.error(`Missing value: ${err.message}`);
} else if (err instanceof PlayError) {
// Any other @xmachines/* error
console.error(`[${err.scope}:${err.code}] ${err.message}`);
} else {
throw err;
}
}

Extend PlayError in your own @xmachines/*-compatible packages:

import { PlayError } from "@xmachines/play/errors";
export class MyPackageError extends PlayError {
constructor(message: string, options?: ErrorOptions) {
super("MyScope", "MY_PACKAGE_ERROR_CODE", message, options);
this.name = "MyPackageError";
}
}

assertNonNullable(value, name?)

The assertion utility returns value with the type NonNullable<V>, or it throws NonNullableError. It removes the unsafe ! non-null assertion.

import { assertNonNullable } from "@xmachines/play";
// DOM element lookup — no intermediate variable or `!` needed:
const el = assertNonNullable(document.getElementById("app"), "#app");

Cleanup, asCleanup(release), and DISPOSE

Every subscription of the ecosystem hands one value back: the release. Cleanup is that type. It is a function AND a Disposable, so one value serves both forms — the caller that keeps the release in a field, and the caller that gives it to a scope.

import { asCleanup, type Cleanup } from "@xmachines/play";
// The explicit form. It stays correct.
const stop = watchSignal(count, render);
stop();
// The scoped form. The scope releases it, and an exception releases it too.
{
using stop = watchSignal(count, render);
render(count.get());
}

asCleanup publishes a release. It gives the SAME function back with the dispose key added, and wraps nothing, so a caller that compares two releases or holds one in a Set sees what it saw before.

export function watchSignal<T>(signal: Signal.State<T>, onValue: (v: T) => void): Cleanup {
const watcher = new Signal.subtle.Watcher(() => {});
watcher.watch(signal);
return asCleanup(() => watcher.unwatch(signal));
}

DISPOSE is the key that the release sits under, and a class implements the protocol with it:

import { DISPOSE } from "@xmachines/play";
class Session {
close(): void {}
[DISPOSE](): void {
this.close();
}
}

Use DISPOSE and not a bare Symbol.dispose. A bundler that downlevels using for a target below Chrome 134 emits a helper that reads object[Symbol.dispose || Symbol.for("Symbol.dispose")], and DISPOSE is that same pair.

The using FORM needs "lib": ["ESNext"], and the Cleanup VALUE needs nothing. A consumer on a lower lib reads Cleanup as the plain () => void that these functions returned before the protocol existed, and every call site keeps compiling.


API Summary

Exported from @xmachines/play

ExportKindDescription
PlayEvent<TPayload>typeUniversal event contract — { type: string } & TPayload
assertNonNullablefunctionAsserts that the value is not null. It returns the narrowed value
CleanuptypeThe release of a subscription — (() => void) & Disposable
asCleanupfunctionPublishes one release as a Cleanup. It changes the function itself
DISPOSEconstThe symbol key that a release sits under
DisposeKeytypeThe type of that key, and never where the consumer omits the lib

Exported from @xmachines/play/errors

ExportKindDescription
PlayErrorclassThe base class of every @xmachines/* typed error
NonNullableErrorclassscope: "assertNonNullable", code: "PLAY_NON_NULLABLE"

Error Codes

CodeClassThrown When
PLAY_NON_NULLABLENonNullableErrorassertNonNullable() receives null or undefined

Every other @xmachines/* package exports its own error subclasses from its ./errors subpath:

PackageImport path
@xmachines/play@xmachines/play/errors
@xmachines/play-router@xmachines/play-router/errors
@xmachines/play-url@xmachines/play-url/errors
@xmachines/play-view@xmachines/play-view/errors
@xmachines/play-xstate@xmachines/play-xstate/errors
@xmachines/play-vue-router@xmachines/play-vue-router/errors

License

MIT © Mikael Karon

See LICENSE for details.

Modules