Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-assert-event-generic-narrowing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'xstate': patch
---

fix(core): fix `assertEvent` narrowing when the event type is behind a generic type parameter

Previously, calling `assertEvent` inside a function whose event parameter was typed with a generic (e.g. `function f<TEvent extends SomeEventUnion>(event: TEvent)`) would compile, but every property access on `event` afterward would fail to type-check with a "Property does not exist" error. This is now fixed - `assertEvent` narrows generic event parameters the same way it narrows concrete ones, while still rejecting invalid event descriptors at compile time.

`assertEvent` also keeps narrowing events whose `type` field is a union of literals (e.g. `{ type: 'a' | 'b'; value: string }`) when asserting one of those literals, for both concrete and generic event parameters.
45 changes: 43 additions & 2 deletions packages/core/src/assert.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,47 @@
import { EventDescriptor, EventObject, ExtractEvent } from './types.ts';
import { EventDescriptor, EventObject } from './types.ts';
import { matchesEventDescriptor, toArray } from './utils.ts';

// `AssertedEvent` is kept local to this module rather than reusing
// `ExtractEvent` (types.ts) so that the narrowing also resolves when `TEvent` is
// an unresolved generic type parameter, e.g.:
//
// function example<T extends SomeEventUnion>(event: T) {
// assertEvent(event, 'someType');
// event.someProp; // previously a type error; now narrows correctly
// }
//
// Two details make that work while still matching what `ExtractEvent` does:
//
// 1. We match `{ type: infer TType }` and test the fresh `TType` parameter
// instead of indexing `TEvent['type']` inside the distribution over
// `TEvent`. Indexing a still-unresolved generic `TEvent` leaves the whole
// conditional opaque, so every property access on the "narrowed" event then
// fails.
// 2. Testing the inferred `TType` also lets the descriptor check distribute
// over a union-typed `type` field, so an event like `{ type: 'a' | 'b' }` is
// still matched by the descriptor `'a'` (mirroring `ExtractEvent`'s
// `EventDescriptorMatches`). Plain assignability
// (`TEvent extends { type: 'a' }`) would instead throw such an event away.
type NormalizeAssertedDescriptor<TDescriptor extends string> =
TDescriptor extends '*'
? string
: TDescriptor extends `${infer TLeading}.*`
? `${TLeading}.${string}`
: TDescriptor;

type AssertedEvent<TEvent extends EventObject, TDescriptor extends string> =
| (TEvent extends { type: infer TType extends string }
? // `true` is the check type here to match both `true` and `boolean`, so a
// member whose `type` is itself a union (e.g. `'a' | 'b'`) still matches
// a descriptor for one of its constituents.
true extends (
TType extends NormalizeAssertedDescriptor<TDescriptor> ? true : false
)
? TEvent
: never
: never)
| (string extends TEvent['type'] ? TEvent : never);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

/**
* 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.
Expand Down Expand Up @@ -30,7 +71,7 @@ export function assertEvent<
>(
event: TEvent,
type: TAssertedDescriptor | readonly TAssertedDescriptor[]
): asserts event is ExtractEvent<TEvent, TAssertedDescriptor> {
): asserts event is AssertedEvent<TEvent, TAssertedDescriptor> {
const types = toArray(type);

const matches = types.some((descriptor) =>
Expand Down
93 changes: 93 additions & 0 deletions packages/core/test/assert.types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { assertEvent, InspectionEvent } from '../src';

describe('assertEvent generics', () => {
it('narrows a non-generic event the same way as before', () => {
function handle(event: InspectionEvent) {
assertEvent(event, '@xstate.event');
event.event satisfies { type: string };
}
});

it('narrows an event behind a generic type parameter (#5448)', () => {
// This previously failed to compile: TS couldn't resolve the asserted
// type for a generic `TEvent`, so every property access on `event`
// after the assertion errored with "Property '...' does not exist".
function handle<TEvent extends InspectionEvent>(event: TEvent) {
assertEvent(event, '@xstate.event');
event.event satisfies { type: string };
}
});

it('excludes properties that only exist on other members of the generic union', () => {
// Guards against a fix that widens the assertion instead of narrowing it
// correctly: `snapshot` exists on other InspectionEvent variants, but not
// on the one asserted here, so it must still be a type error.
function handle<TEvent extends InspectionEvent>(event: TEvent) {
assertEvent(event, '@xstate.event');
// @ts-expect-error
event.snapshot;
}
});

it('still rejects an invalid descriptor for a generic type parameter', () => {
// The compile-time descriptor-validity check must still work when
// `TEvent` is generic - this is what stops `assertEvent(event, 'typo')`
// from silently compiling.
function handle<TEvent extends InspectionEvent>(event: TEvent) {
// @ts-expect-error
assertEvent(event, 'not-a-real-descriptor');
}
});
});

describe('assertEvent with union-typed `type` fields', () => {
type Events =
| { type: 'a' | 'b'; value: string }
| { type: 'c'; count: number };

it('narrows a concrete event whose `type` is a union of literals', () => {
// Regression guard: `{ type: 'a' | 'b' }` is not assignable to
// `{ type: 'a' }`, so an assertion based on plain assignability collapsed
// this member to `never` and `event.value` failed with "Property 'value'
// does not exist on type 'never'". It must still narrow via the matched
// descriptor `'a'`.
function handle(event: Events) {
assertEvent(event, 'a');
event.value satisfies string;
}
});

it('narrows a generic event whose `type` is a union of literals', () => {
function handle<TEvent extends Events>(event: TEvent) {
assertEvent(event, 'a');
event.value satisfies string;
}
});

it('excludes properties from union members that do not match (concrete)', () => {
function handle(event: Events) {
assertEvent(event, 'a');
// @ts-expect-error `count` only exists on the `{ type: 'c' }` member
event.count;
}
});

it('excludes properties from union members that do not match (generic)', () => {
function handle<TEvent extends Events>(event: TEvent) {
assertEvent(event, 'a');
// @ts-expect-error `count` only exists on the `{ type: 'c' }` member
event.count;
}
});

it('still rejects an invalid descriptor for a union-typed `type` field', () => {
function handleConcrete(event: Events) {
// @ts-expect-error
assertEvent(event, 'not-a-real-descriptor');
}
function handleGeneric<TEvent extends Events>(event: TEvent) {
// @ts-expect-error
assertEvent(event, 'not-a-real-descriptor');
}
});
});