diff --git a/.changeset/late-crabs-begin.md b/.changeset/late-crabs-begin.md new file mode 100644 index 00000000..af7643da --- /dev/null +++ b/.changeset/late-crabs-begin.md @@ -0,0 +1,7 @@ +--- +"robot3": minor +--- + +Autocomplete for service.send() + +This makes it so that the event name in `service.send(event)` is inferred from the transitions used to create the machine. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 37f3a95e..d92e0f73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,9 @@ "packages/react-robot", "packages/robot-hooks" ], + "dependencies": { + "expect-type": "^1.1.0" + }, "devDependencies": { "@changesets/cli": "^2.26.0", "bundlesize": "^1.0.0-beta.2", @@ -3536,6 +3539,14 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "dev": true }, + "node_modules/expect-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", + "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -9951,7 +9962,7 @@ }, "packages/core": { "name": "robot3", - "version": "1.0.1", + "version": "1.0.2", "license": "BSD-2-Clause", "devDependencies": { "rollup": "^1.21.4", @@ -9978,11 +9989,11 @@ "license": "BSD-2-Clause", "devDependencies": { "lit": "^3.1.3", - "robot3": "1.0.1" + "robot3": "1.0.2" }, "peerDependencies": { "lit": "^3.1.3", - "robot3": "^1.0.1" + "robot3": "^1.0.2" } }, "packages/lit-robot/node_modules/@lit/reactive-element": { @@ -10072,14 +10083,14 @@ } }, "packages/robot-hooks": { - "version": "1.0.0", + "version": "1.0.1", "license": "BSD-2-Clause", "devDependencies": { "haunted": "^5.0.0", - "robot3": "^1.0.0" + "robot3": "^1.0.2" }, "peerDependencies": { - "robot3": "^1.0.0" + "robot3": "^1.0.2" } } }, @@ -12892,6 +12903,11 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "dev": true }, + "expect-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", + "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==" + }, "extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -14359,7 +14375,7 @@ "version": "file:packages/lit-robot", "requires": { "lit": "^3.1.3", - "robot3": "1.0.1" + "robot3": "1.0.2" }, "dependencies": { "@lit/reactive-element": { @@ -16451,7 +16467,7 @@ "version": "file:packages/robot-hooks", "requires": { "haunted": "^5.0.0", - "robot3": "^1.0.0" + "robot3": "^1.0.2" } }, "robot-site": { diff --git a/package.json b/package.json index b2eceed2..bee4c531 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,9 @@ ], "devDependencies": { "@changesets/cli": "^2.26.0", - "node-qunit-puppeteer": "^2.2.0", "bundlesize": "^1.0.0-beta.2", "local-web-server": "^4.2.1", + "node-qunit-puppeteer": "^2.2.0", "wireit": "^0.9.3" }, "wireit": { @@ -31,5 +31,8 @@ } } } + }, + "dependencies": { + "expect-type": "^1.1.0" } } diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 5a3d09fb..10d2f199 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -9,14 +9,23 @@ declare module 'robot3' { }[keyof T] : never - type AllStateKeys = NestedKeys | keyof T + type AllStateKeys = NestedKeys | keyof T; + + type MachineStates = { + [K in keyof S]: { + final: boolean + transitions: Map[]> + immediates?: Map[]> + enter?: any + } + } /** * The debugging object contains an _onEnter method, wich can be set to invoke * this function on every transition. */ export const d: { - _onEnter?: OnEnterFunction + _onEnter?: OnEnterFunction> } /** @@ -27,11 +36,11 @@ declare module 'robot3' { * @param states - An object of states, where each key is a state name, and the values are one of *state* or *invoke*. * @param context - A function that returns an object of extended state values. The function can receive an `event` argument. */ - export function createMachine( + export function createMachine, C = {}, F extends string = string>( initial: keyof S, - states: { [K in keyof S]: MachineState }, + states: S, context?: ContextFunction - ): Machine> + ): Machine> /** * The `createMachine` function creates a state machine. It takes an object of *states* with the key being the state name. * The value is usually *state* but might also be *invoke*. @@ -39,17 +48,19 @@ declare module 'robot3' { * @param states - An object of states, where each key is a state name, and the values are one of *state* or *invoke*. * @param context - A function that returns an object of extended state values. The function can receive an `event` argument. */ - export function createMachine( - states: { [K in keyof S]: MachineState }, + export function createMachine, C = {}, F extends string = string>( + states: S, context?: ContextFunction - ): Machine> + ): Machine>; /** * The `state` function returns a state object. A state can take transitions and immediates as arguments. * * @param args - Any argument needs to be of type Transition or Immediate. */ - export function state(...args: (Transition | Immediate)[]): MachineState + export function state | Immediate>( + ...args: T[] + ): MachineState ? F : string>; /** * A `transition` function is used to move from one state to another. @@ -58,11 +69,11 @@ declare module 'robot3' { * @param state - The name of the destination state. * @param args - Any extra argument will be evaluated to check if they are one of Reducer, Guard or Action. */ - export function transition( - event: string, + export function transition( + event: F, state: string, ...args: (Reducer | Guard | Action)[] - ): Transition + ): Transition; /** * An `immediate` function is a type of transition that occurs immediately; it doesn't wait for an event to proceed. @@ -71,10 +82,10 @@ declare module 'robot3' { * @param state - The name of the destination state. * @param args - Any extra argument will be evaluated to check if they are a Reducer or a Guard. */ - export function immediate( + export function immediate( state: string, ...args: (Reducer | Guard | Action)[] - ): Transition + ): Transition /** * A `guard` is a method that determines if a transition can proceed. @@ -119,7 +130,7 @@ declare module 'robot3' { * @param fn - Promise-returning function * @param args - Any argument needs to be of type Transition or Immediate. */ - export function invoke(fn: (ctx: C, e?: E) => Promise, ...args: (Transition | Immediate)[]): MachineState + export function invoke(fn: (ctx: C, e?: E) => Promise, ...args: (Transition | Immediate)[]): MachineState /** * The `invoke` is a special type of state that immediately invokes a Promise-returning or Machine-returning function, or another machine. @@ -127,7 +138,7 @@ declare module 'robot3' { * @param fn - Machine-returning function * @param args - Any argument needs to be of type Transition or Immediate. */ - export function invoke(fn: (ctx: C, e?: E) => M, ...args: (Transition | Immediate)[]): MachineState + export function invoke(fn: (ctx: C, e?: E) => M, ...args: (Transition | Immediate)[]): MachineState /** * The `invoke` is a special type of state that immediately invokes a Promise-returning or Machine-returning function, or another machine. @@ -135,7 +146,7 @@ declare module 'robot3' { * @param machine - Machine * @param args - Any argument needs to be of type Transition or Immediate. */ - export function invoke(machine: M, ...args: (Transition | Immediate)[]): MachineState + export function invoke(machine: M, ...args: (Transition | Immediate)[]): MachineState /* General Types */ @@ -151,8 +162,8 @@ declare module 'robot3' { service: Service ) => void - export type SendEvent = string | { type: string; [key: string]: any } - export type SendFunction = (event: T) => void + export type SendEvent = T | { type: T; [key: string]: any } + export type SendFunction = (event: SendEvent & {}) => void /** * This function is invoked before entering a new state and is bound to the debug @@ -164,16 +175,16 @@ declare module 'robot3' { * @param prevState - previous state * @param event - event provoking the state change */ - export type OnEnterFunction = + export type OnEnterFunction> = (machine: M, to: string, state: C, prevState: C, event?: SendEvent) => void - export type Machine = { + export type Machine = {}, C = {}, K = string, F extends string = string> = { context: C current: K states: S state: { name: K - value: MachineState + value: MachineState } } @@ -189,15 +200,15 @@ declare module 'robot3' { fn: GuardFunction } - export interface MachineState { + export interface MachineState { final: boolean - transitions: Map - immediates?: Map + transitions: Map[]> + immediates?: Map[]> enter?: any } - export interface Transition { - from: string | null + export interface Transition { + from: F | null to: string guards: any[] reducers: any[] @@ -208,8 +219,30 @@ declare module 'robot3' { machine: M context: M['context'] onChange: InterpretOnChangeFunction - send: SendFunction + send: SendFunction> + } + + export type Immediate = Transition; + + // Utilities + type IsAny = 0 extends (1 & T) ? true : false; + + // Get state objects from a Machine + type GetMachineStateObject = M['states']; + + // Create mapped type without the final indexing + type GetTransitionsFromStates = { + [K in keyof S]: S[K] extends { transitions: Map>> } + ? IsAny extends true + ? never + : F + : never } - export type Immediate = Transition + type ExtractNonAnyValues = { + [K in keyof T]: IsAny extends true ? never : T[K] + }[keyof T] & {}; + + export type GetMachineTransitions = + ExtractNonAnyValues>>; } diff --git a/packages/core/package.json b/packages/core/package.json index 09dbf947..8aa13be1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -29,6 +29,8 @@ "bundlesize": "wireit", "server": "wireit", "test": "wireit", + "test:types": "wireit", + "test:browser": "wireit", "build:cjs": "wireit", "build": "wireit" }, @@ -74,6 +76,16 @@ } }, "test": { + "dependencies": [ + "test:types", + "test:browser" + ] + }, + "test:types": { + "command": "tsc -p test/types/tsconfig.json", + "files": [] + }, + "test:browser": { "command": "node-qunit-puppeteer http://localhost:1965/test/test.html 10000", "dependencies": [ "server" diff --git a/packages/core/test/types/send.ts b/packages/core/test/types/send.ts new file mode 100644 index 00000000..0e387986 --- /dev/null +++ b/packages/core/test/types/send.ts @@ -0,0 +1,24 @@ +import { expectTypeOf } from 'expect-type'; +import { test } from 'node:test'; +import { + type Service, + createMachine, + transition, + state, +} from 'robot3'; + +test('send(event) is typed', () => { + const machine = createMachine({ + one: state(transition('go-two', 'two')), + two: state(transition('go-one', 'one')), + three: state() + }); + + type Params = Parameters['send']>; + type EventParam = Params[0]; + type StringParams = Extract; + expectTypeOf().toEqualTypeOf<'go-one' | 'go-two'>(); + + type ObjectParams = Extract; + expectTypeOf().toEqualTypeOf<'go-one' | 'go-two'>(); +}); \ No newline at end of file diff --git a/packages/core/test/types/tsconfig.json b/packages/core/test/types/tsconfig.json new file mode 100644 index 00000000..30410119 --- /dev/null +++ b/packages/core/test/types/tsconfig.json @@ -0,0 +1,33 @@ +{ + "include": ["../..", "."], + "exclude": ["../../node_modules"], + "compilerOptions": { + /* Base Options: */ + "esModuleInterop": true, + "skipLibCheck": false, + "target": "es2022", + "allowJs": false, + "moduleDetection": "force", + "isolatedModules": true, + "moduleResolution": "nodenext", + + /* Strictness */ + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + /* AND if you're building for a library: */ + "declaration": true, + + /* AND if you're building for a library in a monorepo: */ + "composite": true, + "declarationMap": true, + + /* If NOT transpiling with TypeScript: */ + "module": "nodenext", + "noEmit": true, + + /* If your code runs in the DOM: */ + "lib": ["es2022", "dom", "dom.iterable"] + } +} \ No newline at end of file