From 41fa89f7efea89d48eb24298eb6dbcccaa08e1b2 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Wed, 19 Aug 2026 15:57:44 +0200 Subject: [PATCH] Add consumer machine type extractors --- .changeset/clean-dodos-extract.md | 7 ++ README.md | 5 +- docs/agent-guide.md | 30 +++++++ .../src/examples/media-player/view.ts | 2 +- .../src/examples/worker-tabs/machine.ts | 2 +- perf/types/exact-channels.ts | 6 +- src/Machine.ts | 66 ++++++++++++-- src/internal/testing/machine/exploration.ts | 2 +- src/internal/testing/machine/trace.ts | 2 +- src/internal/testing/machine/verification.ts | 2 +- src/testing/MachineTest.ts | 8 +- src/unstable/reactivity/AtomMachine.ts | 2 +- typetest/machine/ConsumerTypes.tst.ts | 85 +++++++++++++++++++ 13 files changed, 198 insertions(+), 21 deletions(-) create mode 100644 .changeset/clean-dodos-extract.md create mode 100644 typetest/machine/ConsumerTypes.tst.ts diff --git a/.changeset/clean-dodos-extract.md b/.changeset/clean-dodos-extract.md new file mode 100644 index 0000000..4c22c39 --- /dev/null +++ b/.changeset/clean-dodos-extract.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine": minor +--- + +Add consumer-facing state and startup-input extractors. `Machine.Snapshot`, `Machine.Value`, and `Machine.SnapshotAt` accept either the object returned by `Machine.states` or a machine definition, while preserving exact path validation and excluding control-only paths from `Value`. + +`Machine.Machine.Input` now extracts the decoded startup value and is `never` when the machine uses `Schema.Void`. Code that needs the startup schema should migrate from `Machine.Machine.Input` to `Machine.Machine.InputSchema`; code that previously used `Machine.Machine.Input["Type"]` can use `Machine.Machine.Input` directly. diff --git a/README.md b/README.md index 6bffca1..cbc0ea5 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,10 @@ Keep one-off topology inline in `Machine.states`. Use `Machine.state` only when the same active state definition is mounted more than once; tagged schemas are already reusable without it. For repeated finite regions, derive names with `States.path(...)` so every literal in the path family is checked against the -complete tree. Type full-snapshot helpers as `Machine.Snapshot`. +complete tree. Type full-snapshot helpers as `Machine.Snapshot` +or `Machine.Snapshot`, schema-backed state payloads as +`Machine.Value`, and path-rooted snapshots as +`Machine.SnapshotAt`. ### Construct state through builders diff --git a/docs/agent-guide.md b/docs/agent-guide.md index b7f7518..7ea0688 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -217,6 +217,22 @@ const offeredIfSlot = ( Do not derive this type with `Parameters[0]`; that depends on overload order and does not express ownership by the state definition. +The same extractor accepts a machine when that is the object exported at the +consumer boundary. Use `Value` for a decoded schema-backed state payload and +`SnapshotAt` for the snapshot rooted at one active path: + +```ts +type Complete = Machine.Snapshot +type Session = Machine.Value +type Trading = Machine.SnapshotAt +``` + +`Value` accepts only paths that own a schema, matching `States.get`. +`SnapshotAt` also accepts structural paths, matching `States.getSnapshot`. +Both reject stale or misspelled paths. Prefer these definition- or +machine-bound forms over `.cases.Case.Type`, `typeof States.states`, or +composing `Machine.Machine.States` with raw-tree path extractors. + An active state does not need a schema unless it owns data. Omit `schema` for control-only atomic, compound, parallel, and final states: @@ -918,8 +934,14 @@ Use the exported utility types when another API must preserve the boundary: ```ts type PublicEvent = Machine.Machine.InputEvent type AnyHandledEvent = Machine.Machine.Event +type StartupInput = Machine.Machine.Input +type StartupInputSchema = Machine.Machine.InputSchema ``` +`Input` is the decoded value accepted at startup. It is `never` for a machine +whose input schema is `Schema.Void`; use `InputSchema` only when an API needs +the schema object itself. + `MachineRef.send`, `machineAtom.send`, and `Machine.plan` accept decoded public events or constructions returned by `Machine.events`. Transition handlers receive only decoded events. Raised events additionally accept constructions @@ -932,6 +954,14 @@ Cluster RPC payloads are additionally decoded against the public `events` schemas at the transport boundary. Never repeat an `_tag` within a list or across both configuration lists. +Do not extract `enqueue`, target builders, transition contexts, command or +inspection unions, or event-construction `ReturnType`s into application helper +APIs. Keep commands inside transition resolvers, where the owning state, +protocols, references, and capabilities are inferred. Likewise, do not add +Atom `State` or `Event` aliases: selectors infer from their bridge, while +consumer props use `Snapshot`, `Value`, or `InputEvent` from the exported state +definition or machine. + ## Recoverable state-scoped work Use `from.effect` for one-shot work. Lifecycle callbacks receive the typed diff --git a/examples/playground/src/examples/media-player/view.ts b/examples/playground/src/examples/media-player/view.ts index 5ae99e4..a64ab48 100644 --- a/examples/playground/src/examples/media-player/view.ts +++ b/examples/playground/src/examples/media-player/view.ts @@ -3,7 +3,7 @@ import { Match } from "effect" import { MediaPlayerMachine } from "./machine.ts" import { initialPlaybackData, type LoudnessSample, type PlaybackData } from "./schemas.ts" -type MediaPlayerSnapshot = Machine.Machine.Snapshot> +type MediaPlayerSnapshot = Machine.Snapshot type TransportSnapshot = MediaPlayerSnapshot["states"]["transport"]["state"] type ReadySnapshot = Extract["state"] type SettingsSnapshot = MediaPlayerSnapshot["states"]["settings"]["state"] diff --git a/examples/playground/src/examples/worker-tabs/machine.ts b/examples/playground/src/examples/worker-tabs/machine.ts index b5b8dcd..304476b 100644 --- a/examples/playground/src/examples/worker-tabs/machine.ts +++ b/examples/playground/src/examples/worker-tabs/machine.ts @@ -61,4 +61,4 @@ export const SharedMachine = Machine.make({ } }) -export type SharedSnapshot = Machine.Machine.Snapshot +export type SharedSnapshot = Machine.Snapshot diff --git a/perf/types/exact-channels.ts b/perf/types/exact-channels.ts index d533eac..4032afb 100644 --- a/perf/types/exact-channels.ts +++ b/perf/types/exact-channels.ts @@ -25,7 +25,10 @@ const complete = machine.handle({ } }) -type InputIsExact = Expect["Type"], { readonly seed: number }>> +type InputSchemaIsExact = Expect< + Equal["Type"], { readonly seed: number }> +> +type InputIsExact = Expect, { readonly seed: number }>> type InputEventIsExact = Expect, typeof Start.Type>> type EventIsExact = Expect, typeof Start.Type | typeof Loaded.Type>> type EmitIsExact = Expect, typeof Notice.Type>> @@ -51,6 +54,7 @@ export type { InitialServicesAreExact, InputEventIsExact, InputIsExact, + InputSchemaIsExact, OutputIsExact, OutputIsNotAny, OutputStatesAreExact, diff --git a/src/Machine.ts b/src/Machine.ts index 277742c..196d28d 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -2609,12 +2609,26 @@ export declare namespace Machine { export type Events = M[typeof MachineTypeId]["events"] /** - * Extracts the input schema carried by a machine definition. + * Extracts the startup input schema carried by a machine definition. * * @category utility types - * @since 0.4.0 + * @since 0.18.0 */ - export type Input = M[typeof MachineTypeId]["input"] + export type InputSchema = M[typeof MachineTypeId]["input"] + + /** + * Extracts the decoded startup input accepted by a machine definition. + * + * Machines declared with `Schema.Void` do not accept a startup input, so + * their extracted input type is `never`. + * + * @category utility types + * @since 0.18.0 + */ + export type Input = InputSchema extends infer Input extends Schema.Top + ? Input extends typeof Schema.Void ? never + : Input["Type"] + : never /** * Extracts state paths that do not yet have handlers. @@ -6402,10 +6416,10 @@ export declare namespace Machine { Machine.OutputStates > ? unknown : never), - ...options: Input extends typeof Schema.Void ? [options?: { readonly input?: never }] + ...options: InputSchema extends typeof Schema.Void ? [options?: { readonly input?: never }] : [options: { readonly input: InvokeSource< - Input["Type"], + Input, InvokeContext > }] @@ -7540,15 +7554,49 @@ export declare namespace Machine { > } +type StateSource = Machine.DefinedStates | Machine.Any + +type StateSchemasOf = Source extends Machine.DefinedStates ? States + : Source extends Machine.Any ? Machine.States + : never + /** - * Extracts the complete logical snapshot represented by a state definition. + * Extracts the complete logical snapshot represented by a state definition or + * machine. * * @category utility types * @since 0.15.0 */ -export type Snapshot> = Defined extends Machine.DefinedStates - ? Machine.Snapshot - : never +export type Snapshot = Machine.Snapshot> + +/** + * Extracts the decoded value owned by a schema-backed state path. + * + * The source may be the object returned by {@link states} or a machine + * definition. Control-only state paths are intentionally excluded. + * + * @category utility types + * @since 0.18.0 + */ +export type Value< + Source extends StateSource, + Path extends Machine.ValuedStateIdentifier> +> = Machine.StateByIdentifier, Path> + +/** + * Extracts the logical snapshot rooted at a state path. + * + * The source may be the object returned by {@link states} or a machine + * definition. This is the type-level counterpart of + * `DefinedStates.getSnapshot`. + * + * @category utility types + * @since 0.18.0 + */ +export type SnapshotAt< + Source extends StateSource, + Path extends Machine.StateIdentifier> +> = Machine.SnapshotByIdentifier, Path> /** * Returns `true` if a value is a `Machine`. diff --git a/src/internal/testing/machine/exploration.ts b/src/internal/testing/machine/exploration.ts index 6f96e81..b598d2f 100644 --- a/src/internal/testing/machine/exploration.ts +++ b/src/internal/testing/machine/exploration.ts @@ -34,7 +34,7 @@ import { makeTransitionCoverageCollector } from "./transitionCoverage.js" type AnyMachine = Machine.Machine.Any -type InputValue = Machine.Machine.Input["Type"] +type InputValue = Machine.Machine.Input type ReadyMachine = & M diff --git a/src/internal/testing/machine/trace.ts b/src/internal/testing/machine/trace.ts index 882e80f..8b73529 100644 --- a/src/internal/testing/machine/trace.ts +++ b/src/internal/testing/machine/trace.ts @@ -22,7 +22,7 @@ import type { EnsureExecutable } from "../../machine/readiness.js" type AnyMachine = Machine.Machine.Any -type InputValue = Machine.Machine.Input["Type"] +type InputValue = Machine.Machine.Input type StatePath = Machine.Machine.StateIdentifier> diff --git a/src/internal/testing/machine/verification.ts b/src/internal/testing/machine/verification.ts index bf8269f..5a42355 100644 --- a/src/internal/testing/machine/verification.ts +++ b/src/internal/testing/machine/verification.ts @@ -152,7 +152,7 @@ export const interpretModel = ReferenceModel.interpretModel type AnyMachine = Machine.Machine.Any -type InputValue = Machine.Machine.Input["Type"] +type InputValue = Machine.Machine.Input type StatePath = Machine.Machine.StateIdentifier> diff --git a/src/testing/MachineTest.ts b/src/testing/MachineTest.ts index 6ef20da..0995387 100644 --- a/src/testing/MachineTest.ts +++ b/src/testing/MachineTest.ts @@ -122,7 +122,7 @@ export const interpretModel: (model: FiniteModel, events: ReadonlyArray) type AnyMachine = Machine.Machine.Any -type InputValue = Machine.Machine.Input["Type"] +type InputValue = Machine.Machine.Input type StatePath = Machine.Machine.StateIdentifier> @@ -151,7 +151,7 @@ type RootReadyMachine = * @category models * @since 0.4.0 */ -export type Scenario = Machine.Machine.Input extends typeof Schema.Void ? { +export type Scenario = Machine.Machine.InputSchema extends typeof Schema.Void ? { readonly events: ReadonlyArray> } : { @@ -174,7 +174,7 @@ export type ScenarioOptions = readonly maxEvents?: number readonly eventsArbitrary?: FastCheck.Arbitrary>> } - & (Machine.Machine.Input extends typeof Schema.Void ? { + & (Machine.Machine.InputSchema extends typeof Schema.Void ? { readonly inputArbitrary?: never } : { @@ -1410,7 +1410,7 @@ interface ExploreOptionsBase { */ export type ExploreOptions = & ExploreOptionsBase - & (Machine.Machine.Input extends typeof Schema.Void ? { + & (Machine.Machine.InputSchema extends typeof Schema.Void ? { readonly input?: never } : { diff --git a/src/unstable/reactivity/AtomMachine.ts b/src/unstable/reactivity/AtomMachine.ts index 3d40c59..f623adc 100644 --- a/src/unstable/reactivity/AtomMachine.ts +++ b/src/unstable/reactivity/AtomMachine.ts @@ -582,7 +582,7 @@ type EnsureMachineExecutable = IsAny type MachineInputArgsOf = [ - ...Machine.Machine.InputArgs> + ...Machine.Machine.InputArgs> ] type MachineAtomOf = MachineAtom< diff --git a/typetest/machine/ConsumerTypes.tst.ts b/typetest/machine/ConsumerTypes.tst.ts new file mode 100644 index 0000000..195968f --- /dev/null +++ b/typetest/machine/ConsumerTypes.tst.ts @@ -0,0 +1,85 @@ +import { Schema } from "effect" +import { describe, expect, it } from "tstyche" +import { Machine } from "../../src/index.js" + +class InSession extends Schema.TaggedClass("ConsumerTypesInSession")("InSession", { + offerId: Schema.String, + role: Schema.Literals(["offerer", "proposer"]) +}) {} + +const States = Machine.states({ + root: { + initial: "Idle", + states: { + Idle: {}, + InSession + } + } +}) + +const StartupInput = Schema.Struct({ + offerId: Schema.String, + role: Schema.Literals(["offerer", "proposer"]) +}) + +const definition = Machine.make({ + states: States.states, + events: Machine.events(), + input: StartupInput, + initial: (to) => + to.root.initial.resolve(({ input, target }) => { + expect(input).type.toBe() + return target.from((root) => root.Idle.from()) + }) +}) + +const machine = definition.handle({ + root: { + states: { + Idle: {}, + InSession: {} + } + } +}) + +const voidMachine = Machine.make({ + states: States.states, + events: Machine.events(), + initial: (to) => to.root.initial.resolve(({ target }) => target.from((root) => root.Idle.from())) +}) + +describe("consumer type extractors", () => { + it("extracts complete snapshots from defined states and machines", () => { + expect>().type.toBe>() + expect>().type.toBe>() + }) + + it("extracts schema-backed values from defined states and machines", () => { + expect>().type.toBe() + expect>().type.toBe() + + // @ts-expect-error! + type MissingPath = Machine.Value + // @ts-expect-error! + type StructuralPath = Machine.Value + }) + + it("extracts path-rooted snapshots including structural states", () => { + expect>().type.toBe< + Machine.Machine.SnapshotByIdentifier + >() + expect>().type.toBe< + Machine.Machine.SnapshotByIdentifier + >() + + // @ts-expect-error! + type MissingPath = Machine.SnapshotAt + }) + + it("separates decoded startup input from its schema", () => { + expect>().type.toBe() + expect>().type.toBe() + expect>().type.toBe() + expect>().type.toBe() + }) +})