Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Request: Strengthen finite state machine types #148

Open
tomatrow opened this issue Nov 28, 2024 · 1 comment
Open

Feature Request: Strengthen finite state machine types #148

tomatrow opened this issue Nov 28, 2024 · 1 comment
Labels
enhancement New feature or request help wanted Open to contributions from the community.

Comments

@tomatrow
Copy link

tomatrow commented Nov 28, 2024

Describe the feature in detail (code, mocks, or screenshots encouraged)

e.g. calling send on a machine should have typed arguments.

What type of pull request would this be?

New Feature

Provide relevant links or additional information.

Here's a start.

Some caveats:

  • sharing action names on multiple states fails to type
  • _enter/_exit arguments are unknown[]
  • the glob is not included in the states object
  • only arrow functions seem to work for state handlers
import {
	FiniteStateMachine,
	type FSMLifecycle,
	type FSMLifecycleFn,
	type LifecycleFnMeta
} from "runed"

// this is from type fest
export type UnionToIntersection<Union> =
	// `extends unknown` is always going to be the case and is used to convert the
	// `Union` into a [distributive conditional
	// type](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#distributive-conditional-types).
	(
		Union extends unknown ?
			// The union type is used as the only argument to a function since the union
			// of function arguments is an intersection.
			(distributedUnion: Union) => void
		:	// This won't happen.
			never
	) extends (
		// Infer the `Intersection` type since TypeScript represents the positional
		// arguments of unions of functions as an intersection of the union.
		(mergedIntersection: infer Intersection) => void
	) ?
		// The `& Union` is to allow indexing by the resulting type
		Intersection & Union
	:	never

export type AnyTransition = {
	[s in string]: {
		_enter?: (...args: any[]) => void
		_exit?: (...args: any[]) => void
	} & {
		[K in string]: string | ((...args: any[]) => string | void)
	}
}

export type State<T extends AnyTransition> = string & keyof T
export type Handler<T extends AnyTransition> = UnionToIntersection<T[State<T>]>
export type ActionHandler<T extends AnyTransition> = {
	[K in Exclude<keyof Handler<T>, FSMLifecycle>]: Handler<T>[K]
}
export type Event<T extends AnyTransition> = string & keyof ActionHandler<T>
export type EventArgs<T extends AnyTransition, E extends Event<T>> =
	ActionHandler<T>[E] extends (...args: any[]) => any ? Parameters<ActionHandler<T>[E]>
	:	unknown[]
export type StateHandler<T extends AnyTransition, S extends State<T>> = {
	[E in keyof T[S]]: T[S][E] extends State<T> ?
		T[S][E] // string action
	: T[S][E] extends (...args: any[]) => State<T> | void ?
		E extends FSMLifecycle ?
			FSMLifecycleFn<State<T>, Event<T>> // lifecycle function
		:	T[S][E] // function action
	:	never
}
export type FSMLifecycleFns<T extends AnyTransition> = {
	[K in FSMLifecycle]?: FSMLifecycleFn<State<T>, Event<T>>
}

export type SomeTransition<C extends AnyTransition> = {
	[S in State<C>]: StateHandler<C, S>
}

export declare class TypedFiniteStateMachine<T extends AnyTransition> {
	/** Triggers a new event and returns the new state. */
	send<E extends Event<T>>(event: E, ...args: EventArgs<T, E>): State<T> | void
	/** Debounces the triggering of an event. */
	debounce<E extends Event<T>>(
		wait: number | undefined,
		event: E,
		...args: EventArgs<T, E>
	): Promise<State<T>>
	/** The current state. */
	get current(): State<T>
}

export const createFiniteStateMachine = <T extends AnyTransition>(
	initial: State<T>,
	states: SomeTransition<T>,
	glob: FSMLifecycleFns<T> = {}
): TypedFiniteStateMachine<T> =>
	new FiniteStateMachine(
		initial,
		// @ts-expect-error
		{ ...states, "*": glob }
	)

// example

type MyState = "on" | "off"
type MyAction = "turnOff" | "turnOn" | "disable" | "enable"
type MyMeta = LifecycleFnMeta<MyState, MyAction>

const machine = createFiniteStateMachine("off", {
	on: {
		turnOff: "off",
		_enter: (meta: MyMeta) => {
			console.log({ meta })
		},
		_exit: (meta: MyMeta) => {
			console.log({ meta })
		},
		disable: (number: number) => {
			console.log({ number })
			return "off" as const
		}
	},
	off: {
		turnOn: "on",
		enable: () => {
			return
		}
	}
})

machine.send("enable")
machine.send("disable", 1)
machine.send("turnOn")
machine.send("turnOff")
@tomatrow tomatrow added the enhancement New feature or request label Nov 28, 2024
@tomatrow tomatrow changed the title Strengthen finite state machine types Feature Request: Strengthen finite state machine types Nov 28, 2024
@tomatrow
Copy link
Author

tomatrow commented Nov 29, 2024

Okay, new version, state/events are typed upfront.

This is much simpler.

import { FiniteStateMachine, type FSMLifecycle } from "runed"

export type AnyState = string
export type AnyEvent = string
export type AnyEventMap = { [K in string]: unknown[] }
export type StateHandler<
	State extends AnyState,
	Event extends AnyEvent,
	EventMap extends AnyEventMap
> = {
	[E in Event]?: State
} & {
	[E in keyof EventMap]?: (...args: EventMap[E]) => State | void
} & {
	[K in FSMLifecycle]?: <E extends null | Event | keyof EventMap>(
		meta: E extends null ?
			{
				from: State | null
				to: State
				event: null
			}
		: E extends Event ?
			{
				from: State | null
				to: State
				event: E
			}
		: E extends keyof EventMap ?
			{
				from: State | null
				to: State
				event: E
				args: EventMap[E]
			}
		:	never
	) => void
}

type Transition<State extends AnyState, Event extends AnyEvent, EventMap extends AnyEventMap> = {
	[S in State]: StateHandler<State, Event, EventMap>
} & {
	"*"?: StateHandler<State, Event, EventMap>
}

export declare class TypedFiniteStateMachine<
	State extends AnyState,
	Event extends AnyEvent,
	EventMap extends AnyEventMap
> {
	/** Triggers a new event and returns the new state. */
	send<E extends Event | keyof EventMap>(
		event: E,
		...args: E extends keyof EventMap ? EventMap[E] : never[]
	): State | void
	/** Debounces the triggering of an event. */
	debounce<E extends Event | keyof EventMap>(
		wait: number | undefined,
		event: E,
		...args: E extends keyof EventMap ? EventMap[E] : never[]
	): Promise<State>
	/** The current state. */
	get current(): State
}

export function createFiniteStateMachine<
	State extends AnyState,
	Event extends AnyEvent,
	EventMap extends AnyEventMap = {}
>(
	initial: State,
	states: Transition<State, Event, EventMap>
): TypedFiniteStateMachine<State, Event, EventMap> {
	return new FiniteStateMachine(initial, states)
}

// example

export const machine = createFiniteStateMachine<
	"on" | "off",
	"turnOn",
	{ turnOff: [message: string] }
>("off", {
	on: {
		turnOff(message) {
			console.log({ message })
			return "off"
		}
	},
	off: {
		turnOn: "on"
	},
	"*": {
		_enter(meta) {
			switch (meta.event) {
				case "turnOff":
					// correctly typed
					const [message] = meta.args
					console.log({ message })
					break
				case "turnOn":
					// this is an error
					const [c] = meta.args
					break
			}
		}
	}
})

// correctly typed
machine.send("turnOff", "Hello")

// this is an error
machine.send("turnOn", "Hello")

@huntabyte huntabyte added the help wanted Open to contributions from the community. label Dec 2, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted Open to contributions from the community.
Projects
None yet
Development

No branches or pull requests

2 participants