Essentially, by making the on
property optional throughout your code and utilizing the NonNullable<T>
utility type wherever the type of on
is used programmatically, everything should work seamlessly:
type States<T extends Record<keyof T, { on?: any }>> = { [K in keyof T]: {
on?: Record<keyof (NonNullable<T[K]['on']>), keyof T>,
effect?(send: (action: keyof NonNullable<T[K]['on']>) => void): void;
} }
function createStateMachine<
T extends States<T>>(states: {
initial: keyof T,
states: T
}) {
return function send(arg: KeysOfTransition<NonNullable<T[keyof T]['on']>>) {
};
}
function stateNode<T extends Record<keyof T, PropertyKey>>({ on, effect }: {
on?: T, effect?: (send: (action: keyof T) => void) => void
}) {
return { ...on && { on }, ...effect && { effect } }
}
However, there's a slight hiccup with this approach:
const a = createStateMachine({
initial: 'inactive',
states: {
inactive: stateNode({
on: {
ACTIVATE: 'active'
}
}),
frozen: stateNode({ effect() { console.log("Entered Frozen") } }), // error!
// ~~~~~~
// Type 'Record<string | number | symbol, string | number | symbol>' is not assignable to
// type 'Record<string | number | symbol, "inactive" | "active" | "frozen">'.
active: stateNode({
on: {
DEACTIVATE: 'inactive',
},
effect: send => {
send('DEACTIVATE')
},
})
},
});
// const a: (arg: string | number | symbol) => void
The issue arises from T
being inferred incorrectly when the on
property is omitted. This results in undesirable behavior where the compiler forgets state names and outputs less useful types like (arg: PropertyKey) => void
. To address this, we can refactor the stateNode()
function into an overloaded function for better control:
function stateNode<T extends Record<keyof T, PropertyKey>>(param: {
on: T;
effect?: ((send: (action: keyof T) => void) => void) | undefined;
}): typeof param;
function stateNode(param: { effect?: () => void, on?: undefined }): typeof param;
function stateNode<T extends Record<keyof T, PropertyKey>>({ on, effect }: {
on?: T, effect?: (send: (action: keyof T) => void) => void
}) {
return { ...on && { on }, ...effect && { effect } }
}
With these changes, specifying or omitting the on
property in the stateNode()
calls will yield consistent and desired outcomes as demonstrated below:
const a = createStateMachine({
initial: 'inactive',
states: {
inactive: stateNode({
on: {
ACTIVATE: 'active'
}
}),
frozen: stateNode({ effect() { console.log("Entered Frozen") } }),
active: stateNode({
on: {
DEACTIVATE: 'inactive',
},
effect: send => {
send('DEACTIVATE')
},
})
},
});
// const a: (arg: "ACTIVATE" | "DEACTIVATE") => void
All seems to be working smoothly now!
https://www.typescriptlang.org/play?ts=4.2.3#code/C4TwDgpgBA0hIGcDyAzAKgJwIYDsEEth8B7HAHiQCMArAPigF4orqoIAPYCHAEwSgBKEAMbEMPMgGt4xFMxoAaKAmAZ8OAOb0A-FGkhZ81gC4oOCADcIGANwBYAFCPQkKAGVgWLgjJo2nbj5BETEJfUM0JQBvKFJtU1wQKABfWnomGIBtGCh1PRk5NABdUyjHK...