Skip to content

feat(events)!: Add UnknownEvent to the Event union - #1734

Open
gjtorikian wants to merge 1 commit into
mainfrom
fix/1696-unknown-event-union
Open

gjtorikian wants to merge 1 commit into
mainfrom
fix/1696-unknown-event-union

Conversation

@gjtorikian

Copy link
Copy Markdown
Contributor

Description

UnknownEvent was declared in event.interface.ts but never added to the Event union, and deserializeEvent's default branch cast its result to Event. A consumer writing an exhaustive switch over event.event with an assertNever default therefore compiled clean and threw at runtime the first time the API shipped an event type the SDK did not know (#864, #1360, #1362, #1453, #1685 are all this failure). This was the second half of #1572, which was closed without it landing.

This PR:

  • Adds UnknownEvent to Event and a parallel UnknownEventResponse to EventResponse.
  • Exposes the known members as KnownEvent / KnownEventResponse; EventName is now KnownEvent['event'], so listEvents filters and webhook endpoint events keep their literal typing.
  • Moves the deserializer's switch into deserializeKnownEvent, typed against KnownEvent, with deserializeEvent passing through anything it returns undefined for. The previous as Event cast is gone.
  • Adds a runtime test for the pass-through and a type-level test (enforced by tsc) that a known case keeps its narrowed payload while default can no longer be assertNever'd.

Why UnknownEvent.data is never

TypeScript keeps a union member whose discriminant is string in every narrowed branch: in case 'user.created':, event is UserCreatedEvent | UnknownEvent. With the previously declared data: Record<string, unknown>, event.data would become User | Record<string, unknown> in every branch and event.data.email would be unknown, breaking essentially every consumer (and ~80 cases in the SDK's own serializer). never is absorbed by those unions, so known branches are unchanged. The payload is still passed through unchanged at runtime (snake_case keys, no deserialization) and, since never is assignable to anything, can be read by annotating it:

default: {
  const payload: Record<string, unknown> = event.data;
}

This is the only typing that preserves narrowed reads; there is no TypeScript type for "string except these literals".

Breaking changes

  • Event['event'] is now string rather than the literal union. Code that used it as an EventName (for example indexing Record<EventName, Handler> with event.event) must narrow first or use EventName / KnownEvent['event'].
  • A default branch of a switch over event.event is no longer narrowed, so assertNever(event) there fails to compile. That is the intended signal; handle the unknown case instead.
  • Object literals typed as Event whose event is a union of literals (for example built inside a grouped case clause) are also matched against UnknownEvent by TypeScript's discriminated-union assignability and need an explicit known event type. The SDK's own serializer was adjusted for this; consumers that only read events are unaffected.

Note: the first half of #1572 (plumbing an AbortSignal through RequestOptions) did not land either; RequestOptions still has no signal. That is a separate change.

Fixes #1696

Documentation

Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.

[ ] Yes

If yes, link a related docs PR and add a docs maintainer as a reviewer. Their approval is required.

`UnknownEvent` was declared but not part of `Event`, and `deserializeEvent`
cast its default branch to `Event`, so an exhaustive `switch` over
`event.event` with an `assertNever` default compiled clean and threw at
runtime the first time the API shipped an event type the SDK did not
know. The `Event` union always trails the API, so this eventually hit
every such consumer (#864, #1360, #1362, #1453, #1685).

Add `UnknownEvent` to `Event` and a parallel `UnknownEventResponse` to
`EventResponse`, exposing the known members as `KnownEvent` and
`KnownEventResponse`. `EventName` stays the union of known names.

`UnknownEvent.data` is typed `never`. TypeScript keeps a union member
whose discriminant is `string` in every narrowed branch, so any other
type would widen `event.data` in known branches (`User` would become
`User | Record<string, unknown>`); `never` is absorbed and leaves known
payloads intact. The raw payload is still passed through at runtime and
can be read by annotating it: `const payload: Record<string, unknown> =
event.data`.

The deserializer's switch now lives in `deserializeKnownEvent`, typed
against `KnownEvent`, because object literals with a union-typed `event`
(the grouped cases) are otherwise also matched against `UnknownEvent`.

BREAKING CHANGE: `Event['event']` is now `string` instead of the literal
union; use `EventName` or `KnownEvent['event']` for the literal union. A
`default` branch of a `switch` over `event.event` is no longer narrowed,
so an `assertNever(event)` there fails to compile, which is the intended
signal. Object literals typed as `Event` whose `event` is a union of
literals need an explicit known event type.

Fixes #1696
@gjtorikian
gjtorikian requested review from a team as code owners October 20, 2026 21:21
@gjtorikian
gjtorikian requested a review from stacurry October 20, 2026 21:21
@greptile-apps

greptile-apps Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The change reaches event-list and webhook consumers of the shared event path.

The PR appears safe to merge, with a non-blocking improvement needed in the known-payload type test.

Findings

  1. P2 Payload type check misses widening ▶
Fix with agent prompt
### Issue 1
src/events/events.spec.ts:1760-1761
The known-payload check does not prove that `email` remains a `string`: template interpolation also accepts `unknown`. If `event.data.email` becomes `unknown`, this assertion still compiles, leaving callers' typed reads unprotected. Assign it to a `string` variable, or call a string method, before returning it so the test catches the widening it promises to prevent.

```suggestion
          // The known payload type is intact: `data` is `User`, not a union.
          return `user:${event.data.email.toLowerCase()}`;
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds unknown event members to Event and EventResponse, while exposing known members through KnownEvent and KnownEventResponse.

  • Callers can now see when an event name is unknown.

Acknowledged by gjtorikian: widening Event['event'], rejecting exhaustive defaults, requiring explicit known types for grouped object literals, and using data: never are intentional.

Reviews (1) · Last reviewed commit: "feat(events)!: Add UnknownEvent to the E..." · Reviewed by Greptile

Comment thread src/events/events.spec.ts
Comment on lines +1760 to +1761
// The known payload type is intact: `data` is `User`, not a union.
return `user:${event.data.email}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Payload type check misses widening

The known-payload check does not prove that email remains a string: template interpolation also accepts unknown. If event.data.email becomes unknown, this assertion still compiles, leaving callers' typed reads unprotected. Assign it to a string variable, or call a string method, before returning it so the test catches the widening it promises to prevent.

Suggested change
// The known payload type is intact: `data` is `User`, not a union.
return `user:${event.data.email}`;
// The known payload type is intact: `data` is `User`, not a union.
return `user:${event.data.email.toLowerCase()}`;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/events/events.spec.ts
Line: 1760-1761

Comment:
**Payload type check misses widening**

The known-payload check does not prove that `email` remains a `string`: template interpolation also accepts `unknown`. If `event.data.email` becomes `unknown`, this assertion still compiles, leaving callers' typed reads unprotected. Assign it to a `string` variable, or call a string method, before returning it so the test catches the widening it promises to prevent.

```suggestion
          // The known payload type is intact: `data` is `User`, not a union.
          return `user:${event.data.email.toLowerCase()}`;
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

UnknownEvent is not a member of the Event union (second half of #1572 never landed)

1 participant