Skip to content

Instantly share code, notes, and snippets.

@aleclarson
Last active October 23, 2025 21:34
Show Gist options
  • Save aleclarson/eb01a1f444c65bd4ce19e34743b59766 to your computer and use it in GitHub Desktop.
Save aleclarson/eb01a1f444c65bd4ce19e34743b59766 to your computer and use it in GitHub Desktop.
Remix 3 wrapper-free event support (type safe)
import { Event, events, EventTarget, on } from './framework.ts'
// Extending a built-in class with type-safe events
declare global {
interface Worker {
// Does not exist at runtime. No createEventType wrapper needed.
$rmxEvents: {
message: MessageEvent
}
}
}
declare const worker: Worker
// 2nd argument to `events()` can be an array or a function.
events(worker, (on) => [
// Same signature as standalone `on()` function. Good for bubbling events.
on(Worker, 'message', (event) => {
event satisfies MessageEvent
event.target satisfies Worker
}),
// Lightweight signature for direct, non-bubbling event handling.
on('message', (event) => {
event satisfies MessageEvent
event.target satisfies Worker
}),
])
//
// CUSTOM EVENTS
//
// Declare a custom event type if you need custom properties.
class FooEvent extends Event<'foo'> {
constructor(public readonly foo: string) {
super('foo')
}
}
// Custom event target
class Foo extends EventTarget<FooEvent | 'bar'> {}
// String literal types are coerced to `Event<string>` internally.
Foo.prototype.$rmxEvents satisfies {
foo: FooEvent
bar: Event<'bar'>
}
const foo = new Foo()
// Listening from a Remix component
events(foo, [
on(Foo, 'foo', (event) => {
event satisfies FooEvent
event.target satisfies Foo
}),
])
// ...or with a function
events(foo, (on) => [
on('foo', (event) => {
event satisfies FooEvent
event.target satisfies Foo
}),
])
// Dispatching an event
foo.dispatchEvent(new FooEvent('some value'))
foo.dispatchEvent(new Event('bar'))
// Invalid event types are rejected at compile time
foo.dispatchEvent(new Event('baz'))
// ^ Argument of type 'Event<"baz">' is not assignable to parameter of type 'InferEvent<EventTarget<FooEvent | "bar">, "foo" | "bar">'.
/// <reference lib="dom" />
type PropertiesOf<T> = { [K in keyof T]: T[K] }
export const Event = globalThis.Event as PropertiesOf<globalThis.Event> & {
new <T extends string>(type: T): Event<T>
}
export type Event<T extends string = string> = globalThis.Event & {
type: T
}
const RemixEventTarget = globalThis.EventTarget as {
new <T extends string | Event<string>>(): EventTarget<T>
}
interface RemixEventTarget<T extends string | Event<string> = string>
extends Omit<globalThis.EventTarget, 'dispatchEvent'> {
$rmxEvents: [T] extends [Any]
? any
: {
[K in T extends string ? T : Extract<T, Event>['type']]: K extends T
? Event<K>
: Extract<T, { type: K }>
}
dispatchEvent(event: InferEvent<EventTarget<T>>): boolean
}
export const EventTarget = RemixEventTarget
export type EventTarget<T extends string | Event<string> = string> =
RemixEventTarget<T>
declare class Any {
private __any: true
}
export type InferEventType<T extends EventTarget<any>> = T extends Any
? any
: keyof T['$rmxEvents']
export type InferEvent<
T extends EventTarget<any>,
E extends InferEventType<T> = InferEventType<T>,
> = T['$rmxEvents'][E]
export type InferEventHandler<
T extends EventTarget<any>,
E extends InferEventType<T> = InferEventType<T>,
> = (event: InferEvent<T, E> & { target: T }) => void
export type EventDescriptor = {
type: string
handler: (event: Event) => void
options: AddEventListenerOptions | undefined
}
export declare function on<
T extends EventTarget<any>,
E extends InferEventType<T>,
>(
targetType: new (...args: any[]) => T,
type: E,
handler: InferEventHandler<T, E>,
options?: AddEventListenerOptions,
): EventDescriptor
export declare function on(
targetType: new (...args: any[]) => EventTarget<any>,
type: string,
handler: (event: Event) => void,
options?: AddEventListenerOptions,
): EventDescriptor
type OnFunction = typeof on
export declare function events<T extends EventTarget<any>>(
target: T,
handlers: (on: {
/* Lightweight signature for direct event handling */
<E extends InferEventType<T>>(
type: E,
handler: InferEventHandler<T, E>,
options?: AddEventListenerOptions,
): EventDescriptor
/* Same signature as standalone `on()` function. Good for bubbling events. */
<T extends EventTarget<any>, E extends InferEventType<T>>(
targetType: new (...args: any[]) => T,
type: E,
handler: InferEventHandler<T, E>,
options?: AddEventListenerOptions,
): EventDescriptor
}) => EventDescriptor[],
): void
export declare function events(
target: EventTarget<any>,
handlers: EventDescriptor[] | ((on: OnFunction) => EventDescriptor[]),
): void

I'll add to this document as questions arise.

What's wrong with createEventType?

To me, it feels like an unnecessary abstraction. I've found an approach that provides the same type safety without it. Just use Event and EventTarget subclasses.

What about this approach?

Alternative 1

Why not do it like this? ↓

events(foo, {
  foo(event) {
    event satisfies FooEvent
  },
})

If you do it that way, you can't listen to bubbling events (at least, not with type safety).

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