Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/clean-dodos-extract.md
Original file line number Diff line number Diff line change
@@ -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<M>` 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<M>` to `Machine.Machine.InputSchema<M>`; code that previously used `Machine.Machine.Input<M>["Type"]` can use `Machine.Machine.Input<M>` directly.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof States>`.
complete tree. Type full-snapshot helpers as `Machine.Snapshot<typeof States>`
or `Machine.Snapshot<typeof machine>`, schema-backed state payloads as
`Machine.Value<typeof States, Path>`, and path-rooted snapshots as
`Machine.SnapshotAt<typeof States, Path>`.

### Construct state through builders

Expand Down
30 changes: 30 additions & 0 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,22 @@ const offeredIfSlot = (
Do not derive this type with `Parameters<typeof States.get>[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<typeof machine>
type Session = Machine.Value<typeof States, "root.trading.InSession">
type Trading = Machine.SnapshotAt<typeof machine, "root.trading">
```

`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:

Expand Down Expand Up @@ -918,8 +934,14 @@ Use the exported utility types when another API must preserve the boundary:
```ts
type PublicEvent = Machine.Machine.InputEvent<typeof definition>
type AnyHandledEvent = Machine.Machine.Event<typeof definition>
type StartupInput = Machine.Machine.Input<typeof definition>
type StartupInputSchema = Machine.Machine.InputSchema<typeof definition>
```

`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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/playground/src/examples/media-player/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Machine.Machine.States<typeof MediaPlayerMachine>>
type MediaPlayerSnapshot = Machine.Snapshot<typeof MediaPlayerMachine>
type TransportSnapshot = MediaPlayerSnapshot["states"]["transport"]["state"]
type ReadySnapshot = Extract<TransportSnapshot, { readonly path: "Player.transport.Ready" }>["state"]
type SettingsSnapshot = MediaPlayerSnapshot["states"]["settings"]["state"]
Expand Down
2 changes: 1 addition & 1 deletion examples/playground/src/examples/worker-tabs/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ export const SharedMachine = Machine.make({
}
})

export type SharedSnapshot = Machine.Machine.Snapshot<typeof SharedMachineStates.states>
export type SharedSnapshot = Machine.Snapshot<typeof SharedMachineStates>
6 changes: 5 additions & 1 deletion perf/types/exact-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ const complete = machine.handle({
}
})

type InputIsExact = Expect<Equal<Machine.Machine.Input<typeof complete>["Type"], { readonly seed: number }>>
type InputSchemaIsExact = Expect<
Equal<Machine.Machine.InputSchema<typeof complete>["Type"], { readonly seed: number }>
>
type InputIsExact = Expect<Equal<Machine.Machine.Input<typeof complete>, { readonly seed: number }>>
type InputEventIsExact = Expect<Equal<Machine.Machine.InputEvent<typeof complete>, typeof Start.Type>>
type EventIsExact = Expect<Equal<Machine.Machine.Event<typeof complete>, typeof Start.Type | typeof Loaded.Type>>
type EmitIsExact = Expect<Equal<Machine.Machine.Emit<typeof complete>, typeof Notice.Type>>
Expand All @@ -51,6 +54,7 @@ export type {
InitialServicesAreExact,
InputEventIsExact,
InputIsExact,
InputSchemaIsExact,
OutputIsExact,
OutputIsNotAny,
OutputStatesAreExact,
Expand Down
66 changes: 57 additions & 9 deletions src/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2609,12 +2609,26 @@ export declare namespace Machine {
export type Events<M extends Any> = 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 extends Any> = M[typeof MachineTypeId]["input"]
export type InputSchema<M extends Any> = 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<M extends Any> = InputSchema<M> extends infer Input extends Schema.Top
? Input extends typeof Schema.Void ? never
: Input["Type"]
: never

/**
* Extracts state paths that do not yet have handlers.
Expand Down Expand Up @@ -6402,10 +6416,10 @@ export declare namespace Machine {
Machine.OutputStates<Child["machine"]>
> ? unknown
: never),
...options: Input<Child["machine"]> extends typeof Schema.Void ? [options?: { readonly input?: never }]
...options: InputSchema<Child["machine"]> extends typeof Schema.Void ? [options?: { readonly input?: never }]
: [options: {
readonly input: InvokeSource<
Input<Child["machine"]>["Type"],
Input<Child["machine"]>,
InvokeContext<States, Events, Emits, StateId, InputEvents, ParentEvents>
>
}]
Expand Down Expand Up @@ -7540,15 +7554,49 @@ export declare namespace Machine {
>
}

type StateSource = Machine.DefinedStates<any> | Machine.Any

type StateSchemasOf<Source extends StateSource> = Source extends Machine.DefinedStates<infer States> ? States
: Source extends Machine.Any ? Machine.States<Source>
: 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<any>> = Defined extends Machine.DefinedStates<infer States>
? Machine.Snapshot<States>
: never
export type Snapshot<Source extends StateSource> = Machine.Snapshot<StateSchemasOf<Source>>

/**
* 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<StateSchemasOf<Source>>
> = Machine.StateByIdentifier<StateSchemasOf<Source>, 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<StateSchemasOf<Source>>
> = Machine.SnapshotByIdentifier<StateSchemasOf<Source>, Path>

/**
* Returns `true` if a value is a `Machine`.
Expand Down
2 changes: 1 addition & 1 deletion src/internal/testing/machine/exploration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { makeTransitionCoverageCollector } from "./transitionCoverage.js"

type AnyMachine = Machine.Machine.Any

type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>["Type"]
type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>

type ReadyMachine<M extends AnyMachine> =
& M
Expand Down
2 changes: 1 addition & 1 deletion src/internal/testing/machine/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type { EnsureExecutable } from "../../machine/readiness.js"

type AnyMachine = Machine.Machine.Any

type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>["Type"]
type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>

type StatePath<M extends AnyMachine> = Machine.Machine.StateIdentifier<Machine.Machine.States<M>>

Expand Down
2 changes: 1 addition & 1 deletion src/internal/testing/machine/verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export const interpretModel = ReferenceModel.interpretModel

type AnyMachine = Machine.Machine.Any

type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>["Type"]
type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>

type StatePath<M extends AnyMachine> = Machine.Machine.StateIdentifier<Machine.Machine.States<M>>

Expand Down
8 changes: 4 additions & 4 deletions src/testing/MachineTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export const interpretModel: (model: FiniteModel, events: ReadonlyArray<string>)

type AnyMachine = Machine.Machine.Any

type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>["Type"]
type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>

type StatePath<M extends AnyMachine> = Machine.Machine.StateIdentifier<Machine.Machine.States<M>>

Expand Down Expand Up @@ -151,7 +151,7 @@ type RootReadyMachine<M extends AnyMachine> =
* @category models
* @since 0.4.0
*/
export type Scenario<M extends AnyMachine> = Machine.Machine.Input<M> extends typeof Schema.Void ? {
export type Scenario<M extends AnyMachine> = Machine.Machine.InputSchema<M> extends typeof Schema.Void ? {
readonly events: ReadonlyArray<Machine.Machine.InputEvent<M>>
}
: {
Expand All @@ -174,7 +174,7 @@ export type ScenarioOptions<M extends AnyMachine> =
readonly maxEvents?: number
readonly eventsArbitrary?: FastCheck.Arbitrary<ReadonlyArray<Machine.Machine.InputEvent<M>>>
}
& (Machine.Machine.Input<M> extends typeof Schema.Void ? {
& (Machine.Machine.InputSchema<M> extends typeof Schema.Void ? {
readonly inputArbitrary?: never
}
: {
Expand Down Expand Up @@ -1410,7 +1410,7 @@ interface ExploreOptionsBase<M extends AnyMachine, Key extends ExplorationKey> {
*/
export type ExploreOptions<M extends AnyMachine, Key extends ExplorationKey = ExplorationKey> =
& ExploreOptionsBase<M, Key>
& (Machine.Machine.Input<M> extends typeof Schema.Void ? {
& (Machine.Machine.InputSchema<M> extends typeof Schema.Void ? {
readonly input?: never
}
: {
Expand Down
2 changes: 1 addition & 1 deletion src/unstable/reactivity/AtomMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ type EnsureMachineExecutable<M extends Machine.Machine.Any> = IsAny<Machine.Mach
>

type MachineInputArgsOf<M extends Machine.Machine.Any> = [
...Machine.Machine.InputArgs<Machine.Machine.Input<M>>
...Machine.Machine.InputArgs<Machine.Machine.InputSchema<M>>
]

type MachineAtomOf<M extends Machine.Machine.Any, RuntimeError> = MachineAtom<
Expand Down
85 changes: 85 additions & 0 deletions typetest/machine/ConsumerTypes.tst.ts
Original file line number Diff line number Diff line change
@@ -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<InSession>("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<typeof StartupInput.Type>()
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<Machine.Snapshot<typeof States>>().type.toBe<Machine.Machine.Snapshot<typeof States.states>>()
expect<Machine.Snapshot<typeof machine>>().type.toBe<Machine.Snapshot<typeof States>>()
})

it("extracts schema-backed values from defined states and machines", () => {
expect<Machine.Value<typeof States, "root.InSession">>().type.toBe<InSession>()
expect<Machine.Value<typeof machine, "root.InSession">>().type.toBe<InSession>()

// @ts-expect-error!
type MissingPath = Machine.Value<typeof States, "root.Missing">
// @ts-expect-error!
type StructuralPath = Machine.Value<typeof States, "root.Idle">
})

it("extracts path-rooted snapshots including structural states", () => {
expect<Machine.SnapshotAt<typeof States, "root">>().type.toBe<
Machine.Machine.SnapshotByIdentifier<typeof States.states, "root">
>()
expect<Machine.SnapshotAt<typeof machine, "root.Idle">>().type.toBe<
Machine.Machine.SnapshotByIdentifier<typeof States.states, "root.Idle">
>()

// @ts-expect-error!
type MissingPath = Machine.SnapshotAt<typeof machine, "root.Missing">
})

it("separates decoded startup input from its schema", () => {
expect<Machine.Machine.Input<typeof machine>>().type.toBe<typeof StartupInput.Type>()
expect<Machine.Machine.InputSchema<typeof machine>>().type.toBe<typeof StartupInput>()
expect<Machine.Machine.Input<typeof voidMachine>>().type.toBe<never>()
expect<Machine.Machine.InputSchema<typeof voidMachine>>().type.toBe<typeof Schema.Void>()
})
})