diff --git a/.changeset/fair-lemons-cross.md b/.changeset/fair-lemons-cross.md new file mode 100644 index 0000000000..f303a99df0 --- /dev/null +++ b/.changeset/fair-lemons-cross.md @@ -0,0 +1,47 @@ +--- +'xstate': minor +--- + +Event creators can now be modeled inside of the 2nd argument of `createModel()`, and types for both `context` and `events` will be inferred properly in `createMachine()` when given the `typeof model` as the first generic parameter. + +```ts +import { createModel } from 'xstate/lib/model'; + +const userModel = createModel( + // initial context + { + name: 'David', + age: 30 + }, + // creators (just events for now) + { + events: { + updateName: (value: string) => ({ value }), + updateAge: (value: number) => ({ value }), + anotherEvent: () => ({}) // no payload + } + } +); + +const machine = createMachine({ + context: userModel.initialContext, + initial: 'active', + states: { + active: { + on: { + updateName: { + /* ... */ + }, + updateAge: { + /* ... */ + } + } + } + } +}); + +const nextState = machine.transition( + undefined, + userModel.events.updateName('David') +); +``` diff --git a/packages/core/src/Machine.ts b/packages/core/src/Machine.ts index 9b75b57ff2..47dd89cd8f 100644 --- a/packages/core/src/Machine.ts +++ b/packages/core/src/Machine.ts @@ -9,6 +9,7 @@ import { Typestate } from './types'; import { StateNode } from './StateNode'; +import { Model, ModelContextFrom, ModelEventsFrom } from './model'; export function Machine< TContext = any, @@ -48,6 +49,23 @@ export function Machine< ) as StateMachine; } +export function createMachine< + TModel extends Model, + TContext = ModelContextFrom, + TEvent extends EventObject = ModelEventsFrom, + TTypestate extends Typestate = { value: any; context: TContext } +>( + config: MachineConfig, + options?: Partial> +): StateMachine; +export function createMachine< + TContext, + TEvent extends EventObject = AnyEventObject, + TTypestate extends Typestate = { value: any; context: TContext } +>( + config: MachineConfig, + options?: Partial> +): StateMachine; export function createMachine< TContext, TEvent extends EventObject = AnyEventObject, diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 4a10e74f37..628a99fe1d 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -6,8 +6,19 @@ import type { ExtractEvent, EventObject } from './types'; +import { mapValues } from './utils'; -export interface ContextModel { +type AnyFunction = (...args: any[]) => any; + +type Cast = A1 extends A2 ? A1 : A2; +type Compute = { [K in keyof A]: A[K] } & unknown; +type Prop = K extends keyof T ? T[K] : never; + +export interface Model< + TContext, + TEvent extends EventObject, + TModelCreators = never +> { initialContext: TContext; assign: ( assigner: @@ -15,15 +26,87 @@ export interface ContextModel { | PropertyAssigner>, eventType?: TEventType ) => AssignAction>; + events: Prop; reset: () => AssignAction; } +export type ModelContextFrom< + TModel extends Model +> = TModel extends Model ? TContext : never; + +export type ModelEventsFrom< + TModel extends Model +> = TModel extends Model ? TEvent : never; + +type EventCreator< + Self extends AnyFunction, + Return = ReturnType +> = Return extends object + ? Return extends { + type: any; + } + ? "An event creator can't return an object with a type property" + : Self + : 'An event creator must return an object'; + +type EventCreators = { + [K in keyof Self]: Self[K] extends AnyFunction + ? EventCreator + : 'An event creator must be a function'; +}; + +type ModelCreators = { + events: EventCreators>; +}; + +type FinalEventCreators = { + [K in keyof Self]: Self[K] extends AnyFunction + ? ( + ...args: Parameters + ) => Compute & { type: K }> + : never; +}; + +type FinalModelCreators = { + events: FinalEventCreators>; +}; + +type EventFromEventCreators = { + [K in keyof EventCreators]: EventCreators[K] extends AnyFunction + ? ReturnType + : never; +}[keyof EventCreators]; + export function createModel( initialContext: TContext -): ContextModel { - const model: ContextModel = { +): Model; +export function createModel< + TContext, + TModelCreators extends ModelCreators, + TFinalModelCreators = FinalModelCreators +>( + initialContext: TContext, + creators: TModelCreators +): Model< + TContext, + Cast< + EventFromEventCreators>, + EventObject + >, + TFinalModelCreators +>; +export function createModel(initialContext: object, creators?): unknown { + const eventCreators = creators?.events; + + const model: Model = { initialContext, assign, + events: (eventCreators + ? mapValues(eventCreators, (fn, eventType) => (...args: any[]) => ({ + ...fn(...args), + type: eventType + })) + : undefined) as any, reset: () => assign(initialContext) }; diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index 84b26cc611..d737c4cfee 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -1980,9 +1980,10 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" NEXT: { target: 'gone', actions: [ - stop((ctx) => ctx.machineRef), - stop((ctx) => ctx.promiseRef), - stop((ctx) => ctx.observableRef) + // TODO: type these correctly in TContext + stop((ctx) => (ctx as any).machineRef), + stop((ctx) => (ctx as any).promiseRef), + stop((ctx) => (ctx as any).observableRef) ] } } diff --git a/packages/core/test/model.test.ts b/packages/core/test/model.test.ts index 2abc3e5395..30e658de9a 100644 --- a/packages/core/test/model.test.ts +++ b/packages/core/test/model.test.ts @@ -130,4 +130,73 @@ describe('createModel', () => { expect(resetState.context).toEqual(userModel.initialContext); }); + + it('can model events', () => { + const userModel = createModel( + { + name: 'David', + age: 30 + }, + { + events: { + updateName: (value: string) => ({ value }), + updateAge: (value: number) => { + const payload = { + value + }; + (payload as any).type = 'this should be overwritten'; + return payload; + }, + anotherEvent: () => ({}) + } + } + ); + + // Example of an externally-defined assign action + const assignName = userModel.assign( + { + name: (_, event) => { + return event.value; + } + }, + 'updateName' + ); + + const machine = createMachine({ + context: userModel.initialContext, + initial: 'active', + states: { + active: { + on: { + updateName: { + // pre-defined assign action + actions: assignName + }, + updateAge: { + // inline assign action + actions: userModel.assign((_, e) => { + return { + age: e.value + }; + }) + } + } + } + } + }); + + let updatedState = machine.transition( + undefined, + userModel.events.updateName('Anyone') + ); + + expect(updatedState.context.name).toEqual('Anyone'); + + updatedState = machine.transition( + updatedState, + userModel.events.updateAge(42) + ); + + expect(updatedState.context.age).toEqual(42); + }); }); diff --git a/packages/xstate-react/test/useMachine.test.tsx b/packages/xstate-react/test/useMachine.test.tsx index 04d3dbfb5a..3139a3e3b7 100644 --- a/packages/xstate-react/test/useMachine.test.tsx +++ b/packages/xstate-react/test/useMachine.test.tsx @@ -731,7 +731,7 @@ describe('useMachine (strict mode)', () => { }); it('should not miss initial synchronous updates', () => { - const m = createMachine({ + const m = createMachine<{ count: number }>({ initial: 'idle', context: { count: 0 @@ -749,7 +749,7 @@ describe('useMachine (strict mode)', () => { const App = () => { const [state] = useMachine(m); - return state.context.count; + return <>{state.context.count}; }; const { container } = render(); diff --git a/packages/xstate-react/test/useService.test.tsx b/packages/xstate-react/test/useService.test.tsx index 7e18763a65..aeaf5d772a 100644 --- a/packages/xstate-react/test/useService.test.tsx +++ b/packages/xstate-react/test/useService.test.tsx @@ -189,7 +189,7 @@ describe('useService hook', () => { it('service should accept the 2-argument variant', () => { const service = interpret( - createMachine({ + createMachine<{ value: number }, { type: 'EVENT'; value: number }>({ initial: 'first', states: { first: {