Skip to content

feat: add typed custom events - #85

Open
OnurGumus wants to merge 2 commits into
fable-hub:mainfrom
OnurGumus:feat/typed-custom-events
Open

OnurGumus wants to merge 2 commits into
fable-hub:mainfrom
OnurGumus:feat/typed-custom-events

Conversation

@OnurGumus

Copy link
Copy Markdown
Contributor

Builds on #84 (Hook.useEventListener). Until that merges, this PR also shows its commit. Only the last commit is new here.

Adds typed custom events. One value states an event's name and the type of its detail, and both ends take their types from it:

let itemAdded = customEvent<string * int> "item-added"

// Sender
document.dispatchCustom(itemAdded, ("apples", 3))

// Listener, in a HookComponent or LitElement
Hook.useEventListener(document, itemAdded, fun (name, count) ->
    setLast $"{count} {name}")

Why

With dispatchCustomEvent, the sender boxes a value into detail and each listener casts it back with (e :?> CustomEvent).detail :?> (string * int). Nothing ties those casts to what the sender sent. Change the payload and every listener keeps compiling, then reads a shape that isn't there at runtime.

Here the payload type lives in one place. Sending ("apples", "3") to itemAdded is a compile error (expected to have type 'int' but here has type 'string'). The one unchecked cast left is inside the hook, next to the descriptor that pairs the name with the type.

What's added

  • CustomEventOf<'T> (in DomHelpers): an erased single-case union, so at runtime it's just the name string.
  • customEvent<'T> name: makes one. It's a function, not the union case itself, because F# can't apply type arguments to a union case (CustomEventOf<string> "x" is error FS0717: Unexpected type arguments).
  • EventTarget.dispatchCustom(ev, detail, ?bubbles, ?composed, ?cancelable): works on any EventTarget, not only a LitElement's host, so components that don't hold a reference to each other can talk over document. The defaults match dispatchCustomEvent: all true.
  • Hook.useEventListener(target, ev: CustomEventOf<'T>, handler: 'T -> unit): an overload of the hook from feat: add Hook.useEventListener #84 that hands the handler the typed detail.

Docs: a "Typed custom events" section under Events in docsrc/docs/web-components.md. Its example compiles.

Tests

useEventListener carries a typed detail both ways in HookTest.fs sends a tuple through document and reads it back in a hook component. dotnet build Fable.Lit.sln and npm test pass (39 tests).

🤖 Generated with Claude Code

OnurGumus and others added 2 commits October 19, 2026 08:48
Listens to an event on any EventTarget for as long as the component is
connected, and stops when it disconnects.

It avoids two bugs that pull in opposite directions. Subscribing to the
handler itself and never re-subscribing keeps the closure from the render
that connected the component, so a handler that reads state keeps answering
with the state it was bound with. Re-subscribing whenever the handler
changes fixes that, but costs an add and a remove on the DOM every render,
because a lambda written in a template is a new object each time. So the
listener is added once and reads the handler through a ref that every
render refreshes.

The test covers both: with the ref refresh removed it fails with "1" where
"2" was expected (a stale closure), and with the removal taken out of the
disposable it fails with 3 calls where 2 were expected (a leaked listener).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
customEvent<'T> "name" declares a custom event's name and the type of its
detail in one value. EventTarget.dispatchCustom raises it on any target,
document included, so components that hold no reference to each other can
talk. A Hook.useEventListener overload hands the handler the detail already
typed.

Without it, the sender boxes a value into detail and each listener casts it
back on its own, and nothing ties those casts to the sender: change the
payload and every listener keeps compiling while reading a shape that is no
longer there. Here the one unchecked cast is inside the hook, next to the
descriptor that pairs the name with the type. Sending a payload of the
wrong type is a compile error.

customEvent is a function rather than the union case itself because type
arguments cannot be applied to a union case: CustomEventOf<T> "name" is
error FS0717.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JordanMarr JordanMarr added the after-2.0 Considered after the 2.0 maintenance release (see #90) label Oct 19, 2026

This branch has not been deployed

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

Labels

after-2.0 Considered after the 2.0 maintenance release (see #90)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants