Type-safe, implementation-agnostic event contract framework.
This is a tiny framework for type-safe event-based systems in TypeScript. It's built on the struggle that none of the event-based APIs in TypeScript are strict. Here's an example of a problem:
const target = new EventTarget()
target.addEventListener('greet', handler)
target.dispatchEvent(new CustomEvent('gret')) // Oops, a typo!
TypeScript will not warn or throw despite us making an obvious mistake above. The standard EventTarget
API doesn't accept an events map generic either, leaving us no choice to make it more strict by normal means.
I am convinced that event-based system must be as strict as possible. You don't want to dispatch events you don't expect to handle. The data transferred in those events must be clearly defined. Type-safety must be achieved on build-time with TypeScript. This is precisely what this framework does.
While build-time type-safety is useful, it can be circumvented, opening the event contract to runtime errors. That's why this framework also comes with the support for a runtime schema validation for emitted data.
npm install event-contract
This framework operates on the concept of transports. Transports describe how to handle events and create subscriptions. Absolutely anything can be a transport: from the standard APIs like EventTarget
and MessageChannel
, to custom logic like communication with your database or a third-party service.
In this example, we will implement a custom transport over EventTarget
. Each transport is described using two methods:
push()
describes what to do when a new event is emitter;subscribe()
describes how to handle new subscriptions.
In the context of EventTarget
, we handle push()
by target.dispatchEvent()
, and we handle subscribe()
by target.addEventListener()
. Here's the final transport implementation:
const target = new EventTarget()
new EventContract({
transport: {
push(type, data) {
// Translate pushing a new event to dispatching
// a "MessageEvent" on this event target.
target.dispatchEvent(new MessageEvent(type, { data }))
},
subscribe(type, next) {
const handler = (event: Event) => {
if (event instanceof MessageEvent) {
next(event.data)
}
}
// Add a new listener when a subscription occurs.
target.addEventListener(type, handler)
return () => {
// Unsubscribe from this by removing the listener.
target.removeEventListener(type, handler)
}
},
},
})
The
EventTarget
API is a great choice because it's present in both browser and Node.js, meaning that we can now use that contract in those environments.
Note that this is an example implementation. This framework exports a set of Default transfers that you should use for event contracts over standard JavaScript API.
We highly recommend describing the events of your contract using the schema
option of the EventContract
constructor.
import { z } from 'zod'
import { EventContract, eventTargetTransport } from 'event-contract'
const contract = new EventContract({
transport: eventTargetTransport(),
schema: {
greet: z.string(),
},
})
contract.push('greet', 'John') // ✅
contract.push('greet', 123) // ❌
Created event contract automatically infers event type and payload types from the Zod schema you provide. This gives you both build-time and runtime safety, end-to-end.
You can opt-out from runtime validation by not providing the schema
property to your event contract. In that case, you can still annotate expected event types and their payloads by providing an EventsMap
generic to the EventContract
constructor:
type MyEvents = {
greet: string
}
const contract = new EventsContract<MyEvents>({ transport })
contract.subscribe('greet', (name) => name.toUpperCase()
contract.push('greet', 'John') // ✅ OK!
contract.push('greet', 123) // ❌ "number" is not assignable to type "string"
This approach doesn't provide any runtime data validation so we highly recommend using a Combined events map.
This framework comes with a list of default transfers that implement event contract using various built-in APIs.
eventTargetTransport()
broadcastChannelTransport()
Each built-in transport is a function that returns the event contract options. Provide those options to the EventContract
constructor to use that transport.
import { EventContract, eventTargetTransport } from 'event-contract'
const contract = new EventContract<{ greet: string }>({
transport: eventTargetTransport(),
})
type Events = {
greet: string
}
const contract = new EventContract<Events>({
transport: {
push(type, data) {
// Describe how events should be emitted.
},
subscribe(type, next) {
// Attach a listener when a subscription occurs.
return () => {
// Describe how to unsubscribe from this subscription.
}
},
},
})
contract.subscribe('greet', (name) => {
console.log(`hello, ${name}`)
})
contract.push('greet', 'John')
Unsubscribes from the established subscriptions.
When called without any arguments, the .unsubscribe()
method removes all active subscriptions for all event types. This is useful for freeing memory when you no longer need this contract.
contract.unsubscribe()
If you provide an event type, all the subscriptions of that event type will be removed.
contract.unsubscribe('greet')
You can also provide both the event type and a specific listener function. In that case, only that given listener will be removed.
contract.unsubscribe('greet', listener)
Note that every subscription also returns a function that you can use the unsubscribe the respective listener directly:
const unsubscribe = contract.subscribe('greet', listener)
unsubscribe()
You may have noticed that there isn't something like a .subscribeOnce()
on the contract. Instead, in order to handle a certain event once, you have to unsubscribe that handler explicitly:
contract.subscribe('greet', (name) => {
// Unsubscribe from this handler.
// Note that all subscriptions are bound to themselves,
// allowing you to reference them as "this".
contract.unsubscribe('greet', this)
})
Alternatively, you can use the unsubscribe function returned from every subscription to achieve the same result:
const unsubscribe = contract.subscribe('greet', (name) => {
unsubscribe()
})