Skip to content

Commit

Permalink
Add assertEvent (#4547)
Browse files Browse the repository at this point in the history
* Add assertEvent

* Cleanup

* Support multiple events

* TSDocs

* Changeset

* Update .changeset/cuddly-taxis-walk.md

Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>

* Update packages/core/src/assert.ts

Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>

* Update .changeset/cuddly-taxis-walk.md

Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>

* Update packages/core/src/assert.ts

Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>

* Export

* Cleanup

* Fixes

---------

Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
  • Loading branch information
davidkpiano and Andarist authored Dec 13, 2023
1 parent dd668c6 commit 8e8d2ba
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 22 deletions.
23 changes: 23 additions & 0 deletions .changeset/cuddly-taxis-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'xstate': minor
---

Add `assertEvent(...)` to help provide strong typings for events that can't be easily inferred, such as events in `entry` and `exit` actions, or in `invoke.input`.

The `assertEvent(event, 'someType')` function will _throw_ if the event is not the expected type. This ensures that the `event` is guaranteed to have that type, and assumes that the event object has the expected payload (naturally enforced by TypeScript).

```ts
// ...
entry: ({ event }) => {
assertEvent(event, 'greet');
// event is { type: 'greet'; message: string }

assertEvent(event, ['greet', 'notify']);
// event is { type: 'greet'; message: string }
// or { type: 'notify'; message: string; level: 'info' | 'error' }
},
exit: ({ event }) => {
assertEvent(event, 'doNothing');
// event is { type: 'doNothing' }
}
```
43 changes: 43 additions & 0 deletions packages/core/src/assert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { EventObject } from './types.ts';
import { toArray } from './utils.ts';

/**
* Asserts that the given event object is of the specified type or types.
* Throws an error if the event object is not of the specified types.
@example
```ts
// ...
entry: ({ event }) => {
assertEvent(event, 'doNothing');
// event is { type: 'doNothing' }
},
// ...
exit: ({ event }) => {
assertEvent(event, 'greet');
// event is { type: 'greet'; message: string }
assertEvent(event, ['greet', 'notify']);
// event is { type: 'greet'; message: string }
// or { type: 'notify'; message: string; level: 'info' | 'error' }
},
```
*/
export function assertEvent<
TEvent extends EventObject,
TAssertedType extends TEvent['type']
>(
event: TEvent,
type: TAssertedType | TAssertedType[]
): asserts event is TEvent & { type: TAssertedType } {
const types = toArray(type);
if (!types.includes(event.type as any)) {
const typesText =
types.length === 1
? `type "${types[0]}"`
: `one of types "${types.join('", "')}"`;
throw new Error(
`Expected event ${JSON.stringify(event)} to have ${typesText}`
);
}
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export {
StateNode,
type Interpreter
};
export { assertEvent } from './assert.ts';

declare global {
interface SymbolConstructor {
Expand Down
4 changes: 1 addition & 3 deletions packages/core/test/actor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,7 @@ describe('spawning machines', () => {
SET_COMPLETE: {
actions: sendTo(
({ context, event }) => {
return context.todoRefs[
(event as Extract<TodoEvent, { type: 'SET_COMPLETE' }>).id
];
return context.todoRefs[event.id];
},
{ type: 'SET_COMPLETE' }
)
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/after.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ describe('delayed transitions', () => {
delays: {
someDelay: ({ event }) => {
spy(event);
return (event as any).delay;
return event.delay;
}
}
}
Expand Down
100 changes: 100 additions & 0 deletions packages/core/test/assert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { createActor, createMachine, assertEvent } from '../src';

describe('assertion helpers', () => {
it('assertEvent asserts the correct event type', (done) => {
const machine = createMachine(
{
types: {
events: {} as
| { type: 'greet'; message: string }
| { type: 'count'; value: number }
},
on: {
greet: { actions: 'greet' },
count: { actions: 'greet' }
}
},
{
actions: {
greet: ({ event }) => {
// @ts-expect-error
event.message;

assertEvent(event, 'greet');
event.message satisfies string;

// @ts-expect-error
event.count;
}
}
}
);

const actor = createActor(machine);

actor.subscribe({
error(err) {
expect(err).toMatchInlineSnapshot(
`[Error: Expected event {"type":"count","value":42} to have type "greet"]`
);
done();
}
});

actor.start();

actor.send({ type: 'count', value: 42 });
});

it('assertEvent asserts multiple event types', (done) => {
const machine = createMachine(
{
types: {
events: {} as
| { type: 'greet'; message: string }
| { type: 'notify'; message: string; level: 'info' | 'error' }
| { type: 'count'; value: number }
},
on: {
greet: { actions: 'greet' },
count: { actions: 'greet' }
}
},
{
actions: {
greet: ({ event }) => {
// @ts-expect-error
event.message;

assertEvent(event, ['greet', 'notify']);
event.message satisfies string;

// @ts-expect-error
event.level;

assertEvent(event, ['notify']);
event.level satisfies 'info' | 'error';

// @ts-expect-error
event.count;
}
}
}
);

const actor = createActor(machine);

actor.subscribe({
error(err) {
expect(err).toMatchInlineSnapshot(
`[Error: Expected event {"type":"count","value":42} to have one of types "greet", "notify"]`
);
done();
}
});

actor.start();

actor.send({ type: 'count', value: 42 });
});
});
25 changes: 7 additions & 18 deletions packages/core/test/interpreter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { interval, from } from 'rxjs';
import { fromObservable } from '../src/actors/observable';
import { PromiseActorLogic, fromPromise } from '../src/actors/promise';
import { fromCallback } from '../src/actors/callback';
import { assertEvent } from '../src/assert.ts';

const lightMachine = createMachine({
id: 'light',
Expand Down Expand Up @@ -246,15 +247,7 @@ describe('interpreter', () => {
{ type: 'FINISH' },
{
delay: ({ context, event }) =>
context.initialDelay +
('wait' in event
? (
event as Extract<
DelayExpMachineEvents,
{ type: 'ACTIVATE' }
>
).wait
: 0)
context.initialDelay + ('wait' in event ? event.wait : 0)
}
),
on: {
Expand Down Expand Up @@ -327,14 +320,10 @@ describe('interpreter', () => {
entry: raise(
{ type: 'FINISH' },
{
delay: ({ context, event }) =>
context.initialDelay +
(
event as Extract<
DelayExpMachineEvents,
{ type: 'ACTIVATE' }
>
).wait
delay: ({ context, event }) => {
assertEvent(event, 'ACTIVATE');
return context.initialDelay + event.wait;
}
}
),
on: {
Expand Down Expand Up @@ -423,7 +412,7 @@ describe('interpreter', () => {
return context.delay + 50;
},
delayA: ({ context }) => context.delay,
delayD: ({ context, event }) => context.delay + (event as any).value
delayD: ({ context, event }) => context.delay + event.value
}
}
);
Expand Down

0 comments on commit 8e8d2ba

Please sign in to comment.