From 817b39f4910760a7735a5bf53e581f013e3e8c9c Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sun, 2 Aug 2026 14:07:14 +0200 Subject: [PATCH 1/2] Improve compile-time machine diagnostics --- src/Machine.ts | 350 +++++++++++++++++++++++++++++----------- typetest/Machine.tst.ts | 19 +++ 2 files changed, 271 insertions(+), 98 deletions(-) diff --git a/src/Machine.ts b/src/Machine.ts index bb637a6..e7ee4c5 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -509,6 +509,18 @@ type ValidateDefinedStates = [States] exten [Machine.ValidateStateSchemas] ? [] : [validation: Machine.ValidateStateSchemas] +type InvalidDefinedStateTreeInput = [States] extends + [Machine.ValidateStateSchemas] ? never + : States & Machine.ValidateStateSchemas + +interface DefineStates { + ( + states: States & DefineStateTreeInput>, + ..._validation: ValidateDefinedStates> + ): Machine.DefinedStates + (states: InvalidDefinedStateTreeInput): never +} + type EventProtocolError = { readonly "~effect/Machine/EventProtocolError": Message readonly tag: Tag @@ -526,19 +538,23 @@ type DuplicateEventTag< : never : never -type ValidateEventProtocol< +type ValidateInputEventProtocol< + InputEvents extends ReadonlyArray, + DuplicateInput extends PropertyKey = DuplicateEventTag +> = [DuplicateInput] extends [never] ? unknown + : EventProtocolError<"Public event tags must be unique", DuplicateInput> + +type ValidateInternalEventProtocol< InputEvents extends ReadonlyArray, InternalEvents extends ReadonlyArray, - DuplicateInput extends PropertyKey = DuplicateEventTag, DuplicateInternal extends PropertyKey = DuplicateEventTag, Overlap extends PropertyKey = Extract< Machine.TagOf, Machine.TagOf > -> = [DuplicateInput] extends [never] ? [DuplicateInternal] extends [never] ? [Overlap] extends [never] ? [] - : [validation: EventProtocolError<"Public and internal event tags must be disjoint", Overlap>] - : [validation: EventProtocolError<"Internal event tags must be unique", DuplicateInternal>] - : [validation: EventProtocolError<"Public event tags must be unique", DuplicateInput>] +> = [DuplicateInternal] extends [never] ? [Overlap] extends [never] ? unknown + : EventProtocolError<"Public and internal event tags must be disjoint", Overlap> + : EventProtocolError<"Internal event tags must be unique", DuplicateInternal> const SnapshotBuilderStateTypeId: unique symbol = Symbol("effect/Machine/SnapshotBuilderState") const SnapshotBuilderConstructionTypeId: unique symbol = Symbol("effect/Machine/SnapshotBuilderConstruction") @@ -3703,12 +3719,10 @@ export declare namespace Machine { | "output" | "states" - type HandlerValidationError = { - readonly "~effect/Machine/HandlerError": Message + type HandlerValidationError = { + readonly "~effect/Machine/HandlerError": readonly [message: Message, path: Path, detail: Detail] } - type HandlerValidationErrors = Validation extends HandlerValidationError ? Validation : never - type NodeHasDeclaredOutput< States extends StateSchemas, StateId extends StateIdentifier @@ -3762,29 +3776,47 @@ export declare namespace Machine { Config > = "output" extends keyof Config ? StateId : never - type UnionToIntersection = ( - Union extends unknown ? (argument: Union) => void : never - ) extends (argument: infer Intersection) => void ? Intersection - : never - type HandlerUnknownStateKeyValidation< States extends StateSchemas, - Config - > = [Exclude, Extract>] extends [never] ? unknown - : HandlerValidationError<"Handler tree contains a state key that does not exist"> + Prefix extends string, + Config, + UnknownKeys extends string = Exclude, Extract> + > = [UnknownKeys] extends [never] ? unknown : { + readonly [Key in UnknownKeys]: HandlerValidationError< + "Handler tree contains a state key that does not exist", + JoinPath, + Key + > + } - type HandlerUnknownConfigKeyValidation = [ - Exclude, HandlerNodeConfigKey> - ] extends [never] ? unknown - : HandlerValidationError<"Handler config contains an unknown key"> + type HandlerUnknownConfigKeyValidation< + StateId extends string, + Config, + UnknownKeys extends string = Exclude, HandlerNodeConfigKey> + > = [UnknownKeys] extends [never] ? unknown : { + readonly [Key in UnknownKeys]: HandlerValidationError< + "Handler config contains an unknown key", + StateId, + Key + > + } type HandlerOnKeyValidation< Events extends ReadonlyArray, - Config - > = Config extends { readonly on?: infer On } ? [ - Exclude, string>, TagOf> - ] extends [never] ? unknown - : HandlerValidationError<"Handler config contains an event key that does not exist"> + StateId extends string, + Config, + On = Config extends { readonly on?: infer Current } ? NonNullable : never, + UnknownKeys extends string = Exclude, TagOf> + > = "on" extends keyof Config ? [UnknownKeys] extends [never] ? unknown + : { + readonly on: { + readonly [Key in UnknownKeys]: HandlerValidationError< + "Handler config contains an event key that does not exist", + StateId, + Key + > + } + } : unknown type HandlerDepth = readonly [unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown] @@ -3797,26 +3829,39 @@ export declare namespace Machine { AllStates extends StateSchemas, Node, Events extends ReadonlyArray, + Emits extends ReadonlyArray, Prefix extends string, Config, AvailableOutputStates extends StateIdentifier, Depth extends ReadonlyArray - > = "states" extends keyof Config ? - Depth extends readonly [] ? HandlerValidationError<"Handler nesting exceeds the supported depth"> + > = "states" extends keyof Config ? Depth extends readonly [] ? { + readonly states: HandlerValidationError<"Handler nesting exceeds the supported depth", Prefix> + } : Config extends { readonly states?: infer ChildrenConfig } ? - HandlerChildren extends infer Children extends StateSchemas ? - [Children] extends [never] ? - HandlerValidationError<"Handler config contains child states for a state that has no children"> + HandlerChildren extends infer Children extends StateSchemas ? [Children] extends [never] ? { + readonly states: HandlerValidationError< + "Handler config contains child states for a state that has no children", + Prefix + > + } : HandlerTreeValidation< AllStates, Children, Events, + Emits, Prefix, NonNullable, AvailableOutputStates, HandlerNextDepth + > extends infer Validation ? unknown extends Validation ? unknown + : { readonly states: Validation } + : never + : { + readonly states: HandlerValidationError< + "Handler config contains child states for a state that has no children", + Prefix > - : HandlerValidationError<"Handler config contains child states for a state that has no children"> + } : unknown : unknown @@ -3829,12 +3874,24 @@ export declare namespace Machine { & ("onDone" extends keyof Config ? [ Exclude, AvailableOutputStates> ] extends [never] ? unknown - : HandlerValidationError<"Handler config is missing an output implementation required by onDone"> + : { + readonly onDone: HandlerValidationError< + "Handler config is missing an output implementation required by onDone", + StateId, + Exclude, AvailableOutputStates> + > + } : unknown) & ("output" extends keyof Config ? NodeByIdentifier extends { readonly type: "parallel" } ? [ Exclude, AvailableOutputStates> ] extends [never] ? unknown - : HandlerValidationError<"Handler config is missing a region output implementation required by parallel output"> + : { + readonly output: HandlerValidationError< + "Handler config is missing a region output implementation required by parallel output", + StateId, + Exclude, AvailableOutputStates> + > + } : unknown : unknown) @@ -3842,93 +3899,138 @@ export declare namespace Machine { AllStates extends StateSchemas, Node, Events extends ReadonlyArray, + Emits extends ReadonlyArray, StateId extends StateIdentifier, Config, AvailableOutputStates extends StateIdentifier, Depth extends ReadonlyArray > = - & HandlerUnknownConfigKeyValidation - & HandlerOnKeyValidation - & HandlerInvokeOutputValidation - & HandlerInvokeEmitsValidation - & HandlerInvokeSnapshotValidation - & HandlerChildrenValidation + & HandlerUnknownConfigKeyValidation + & HandlerOnKeyValidation + & HandlerInvokeOutputValidation + & HandlerInvokeEmitsValidation + & HandlerInvokeSnapshotValidation + & HandlerChildrenValidation & HandlerOutputRequirementValidation + & HandlerRuntimeValidation type HandlerInvokeOutputValidation< Events extends ReadonlyArray, + StateId extends string, Config > = [InvokeReturn] extends [never] ? unknown : [Exclude>, EventOf | void>] extends [never] ? unknown - : HandlerValidationError<"Invoked child output must be a machine event or void"> + : { + readonly invoke: HandlerValidationError< + "Invoked child output must be a machine event or void", + StateId, + Exclude>, EventOf | void> + > + } type HandlerInvokeEmitsValidation< Events extends ReadonlyArray, + StateId extends string, Config > = [InvokeReturn] extends [never] ? unknown : [Exclude>, EventOf>] extends [never] ? unknown - : HandlerValidationError<"Invoked child emits events not accepted by the parent machine"> + : { + readonly invoke: HandlerValidationError< + "Invoked child emits events not accepted by the parent machine", + StateId, + Exclude>, EventOf> + > + } type HandlerInvokeSnapshotValidation< Events extends ReadonlyArray, + StateId extends string, Config > = [InvokeReturn] extends [never] ? unknown : IsAny>> extends true ? unknown : [Exclude>, EventOf | undefined>] extends [never] ? unknown - : HandlerValidationError<"Invoked child snapshot mapper must return a machine event or undefined"> + : { + readonly invoke: HandlerValidationError< + "Invoked child snapshot mapper must return a machine event or undefined", + StateId, + Exclude>, EventOf | undefined> + > + } - type HandlerTreeNodeValidationErrors< + type HandlerRuntimeValidation< + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends string, + Config, + Incompatible = IncompatibleRuntime< + ConfigServices>, + EventOf, + EmitOf + > + > = [Incompatible] extends [never] ? unknown + : HandlerValidationError<"Handler config requires an incompatible machine runtime", StateId, Incompatible> + + type HandlerTreeNodeValidationMap< AllStates extends StateSchemas, States extends StateSchemas, Events extends ReadonlyArray, + Emits extends ReadonlyArray, Prefix extends string, Config, AvailableOutputStates extends StateIdentifier, Depth extends ReadonlyArray > = { - readonly [Key in Extract, Extract>]: HandlerValidationErrors< - HandlerNodeValidation< - AllStates, - States[Key], - Events, - HandlerStateId>, - Config[Key], - AvailableOutputStates, - Depth - > + readonly [Key in Extract, Extract>]?: HandlerNodeValidation< + AllStates, + States[Key], + Events, + Emits, + HandlerStateId>, + Config[Key], + AvailableOutputStates, + Depth > - }[Extract, Extract>] + } + + type HandlerTreeNodeValidationErrors = { + readonly [Key in keyof Validations as unknown extends Validations[Key] ? never : Key]?: Validations[Key] + } type HandlerTreeNodeValidations< AllStates extends StateSchemas, States extends StateSchemas, Events extends ReadonlyArray, + Emits extends ReadonlyArray, Prefix extends string, Config, AvailableOutputStates extends StateIdentifier, Depth extends ReadonlyArray - > = HandlerTreeNodeValidationErrors< + > = HandlerTreeNodeValidationMap< AllStates, States, Events, + Emits, Prefix, Config, AvailableOutputStates, Depth - > extends infer Errors ? [Errors] extends [never] ? unknown : UnionToIntersection + > extends infer Validations ? + HandlerTreeNodeValidationErrors extends infer Errors ? keyof Errors extends never ? unknown : Errors + : never : never type HandlerTreeValidation< AllStates extends StateSchemas, States extends StateSchemas, Events extends ReadonlyArray, + Emits extends ReadonlyArray, Prefix extends string, Config, AvailableOutputStates extends StateIdentifier, Depth extends ReadonlyArray = HandlerDepth > = - & HandlerUnknownStateKeyValidation - & HandlerTreeNodeValidations + & HandlerUnknownStateKeyValidation + & HandlerTreeNodeValidations type HandlerNodeChildrenConfig = "states" extends keyof Config ? Config extends { readonly states?: infer Children } ? NonNullable @@ -4193,14 +4295,14 @@ export declare namespace Machine { States, States, Events, + Emits, "", - Config, - OutputStates | Extract, StateIdentifier> - > - & EnsureCompatibleRuntime< - HandlerTreeServices, - EventOf, - EmitOf + NoInfer, + | OutputStates + | Extract< + HandlerTreeOutputStates>, + StateIdentifier + > > ): HandleTreeResult< States, @@ -4869,11 +4971,8 @@ const makeTargetBuilder = ( * @category constructors * @since 4.0.0 */ -export const defineStates = < - const States extends Machine.StateSchemas ->( - states: States & DefineStateTreeInput>, - ..._validation: ValidateDefinedStates> +export const defineStates: DefineStates = (( + states: States ): Machine.DefinedStates => ({ states: states as States, initial: makeSnapshotBuilder(states as States, { mode: "initial", prefix: "" }) as Machine.InitialBuilder, @@ -4889,7 +4988,82 @@ export const defineStates = < }) as Machine.DefinedStates["getWithParents"], getSnapshot: Model.getSnapshotByPath as unknown as Machine.DefinedStates["getSnapshot"], matches: (snapshot, path) => Option.isSome(Model.getSnapshotByPath(snapshot, path)) -}) +})) as DefineStates + +type MakeConfig< + States extends Machine.StateSchemas, + InputEvents extends ReadonlyArray, + Emits extends ReadonlyArray, + Input extends Schema.Top, + InitialE, + InitialR, + InternalEvents extends ReadonlyArray +> = { + readonly id?: string + readonly states: States & DefineStateTreeInput> + readonly events: InputEvents & ValidateInputEventProtocol> + readonly internalEvents?: + & InternalEvents + & ValidateInternalEventProtocol< + NoInfer, + NoInfer + > + readonly emits?: Emits + readonly input?: Input + readonly initial: (...args: [...Machine.InputArgs]) => Machine.InitialResult +} + +type MakeResult< + States extends Machine.StateSchemas, + InputEvents extends ReadonlyArray, + Emits extends ReadonlyArray, + Input extends Schema.Top, + InitialE, + InitialR, + InternalEvents extends ReadonlyArray +> = Machine< + States, + readonly [...InputEvents, ...InternalEvents], + Input, + Machine.StateIdentifier, + never, + never, + InitialE, + InitialR, + Machine.FinalStateFromDefinition, + Machine.TerminalOutput, + Emits, + never, + InputEvents +> + +interface Make { + < + const States extends Machine.StateSchemas, + const InputEvents extends ReadonlyArray, + const Emits extends ReadonlyArray = readonly [], + const Input extends Schema.Top = typeof Schema.Void, + InitialE = never, + InitialR = never, + const InternalEvents extends ReadonlyArray = readonly [] + >( + config: MakeConfig, + ..._validation: ValidateDefinedStates> + ): MakeResult + < + const States extends Machine.StateSchemas, + const InputEvents extends ReadonlyArray, + const Emits extends ReadonlyArray = readonly [], + const Input extends Schema.Top = typeof Schema.Void, + InitialE = never, + InitialR = never, + const InternalEvents extends ReadonlyArray = readonly [] + >( + config: + & Omit, "states"> + & { readonly states: InvalidDefinedStateTreeInput } + ): never +} /** * Creates a schema-first machine definition. @@ -4941,7 +5115,7 @@ export const defineStates = < * @category constructors * @since 4.0.0 */ -export const make = < +export const make: Make = (< const States extends Machine.StateSchemas, const InputEvents extends ReadonlyArray, const Emits extends ReadonlyArray = readonly [], @@ -4952,34 +5126,14 @@ export const make = < >( config: { readonly id?: string - readonly states: States & DefineStateTreeInput> - /** Events accepted through public machine input boundaries. */ + readonly states: States readonly events: InputEvents - /** Events delivered only by machine-local logic. */ readonly internalEvents?: InternalEvents readonly emits?: Emits readonly input?: Input readonly initial: (...args: [...Machine.InputArgs]) => Machine.InitialResult - }, - ..._validation: [ - ...ValidateDefinedStates>, - ...ValidateEventProtocol, NoInfer> - ] -): Machine< - States, - readonly [...InputEvents, ...InternalEvents], - Input, - Machine.StateIdentifier, - never, - never, - InitialE, - InitialR, - Machine.FinalStateFromDefinition, - Machine.TerminalOutput, - Emits, - never, - InputEvents -> => { + } +): MakeResult => { const self = Object.create(Proto) self.states = config.states self.events = config.events @@ -4994,7 +5148,7 @@ export const make = < self.handle = makeHandle(self) Model.setProtocol(self) return self -} +}) as Make /** * Encodes a decoded machine snapshot into a normalized data representation. diff --git a/typetest/Machine.tst.ts b/typetest/Machine.tst.ts index a3f534b..dfa1a9e 100644 --- a/typetest/Machine.tst.ts +++ b/typetest/Machine.tst.ts @@ -426,6 +426,25 @@ describe("Machine", () => { expect>().type.toBe() }) + it("handle rejects incompatible machine runtime requirements", () => { + const machine = Machine.make({ + states: UpStates.states, + events: [SignIn], + initial: () => UpStates.initial.down(new Down({})) + }) + + expect(machine.handle).type.toBeCallableWith({ + down: { + entry: () => Machine.runtime<{ readonly events: SignIn }>() + } + }) + expect(machine.handle).type.not.toBeCallableWith({ + down: { + entry: () => Machine.runtime<{ readonly events: Down }>() + } + }) + }) + it("keeps staged action errors and services out of planning", () => { const machine = Machine.make({ states: UpStates.states, From ea8e1657984e404b0f9d72e228b6761cf1d93f2b Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sun, 2 Aug 2026 14:24:09 +0200 Subject: [PATCH 2/2] Add patch changeset for type diagnostics --- .changeset/clear-machines-diagnose.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/clear-machines-diagnose.md diff --git a/.changeset/clear-machines-diagnose.md b/.changeset/clear-machines-diagnose.md new file mode 100644 index 0000000..d8ea753 --- /dev/null +++ b/.changeset/clear-machines-diagnose.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine": patch +--- + +Improve compile-time diagnostics for invalid state definitions, event protocols, +and handler configurations. Errors now retain the relevant configuration shape +and state path while preserving existing inference and type safety.