Skip to content

Instantly share code, notes, and snippets.

@sidola
Last active January 24, 2023 05:13
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save sidola/3b267f21c872e449ef4bbdae9e2baeab to your computer and use it in GitHub Desktop.
Save sidola/3b267f21c872e449ef4bbdae9e2baeab to your computer and use it in GitHub Desktop.
Basic typesafe pub-sub implementation in Typescript
/* ------------------------------------------
Alternative impl. as a class can be found here: https://gist.github.com/sidola/eaf987d8c4c7e8445b61dc07c33a842f
Has a way smaller footprint and less typescript magic to grasp.
------------------------------------------ */
/**
* Defines the function type of the publish function.
*
* Extracts the keys from `E` as valid event types, and the matching
* property as the payload.
*/
type PubTypeFn<E> = <Key extends string & keyof E>(
event: Key,
message: E[Key]
) => void
/**
* Defines the function type for the subscribe function.
*
* Extracts the keys from `E` as valid event types, and the matching
* property as the payload to the callback function.
*
* Returns the given callback.
*/
type SubTypeFn<E> = <Key extends string & keyof E>(
event: Key,
fn: (message: E[Key]) => void
) => (message: E[Key]) => void
/**
* Defines the function type for the unsubscribe function.
*
* Extracts the keys from `E` as valid event types, and the matching
* property as the payload to the callback function.
*/
type UnsubTypeFn<E> = <Key extends string & keyof E>(
event: Key,
fn: (message: E[Key]) => void
) => void
/**
* Tie everything together.
*/
type PubSubType<E> = {
publish: PubTypeFn<E>
subscribe: SubTypeFn<E>
unsubscribe: UnsubTypeFn<E>
}
/**
* Creates a new PubSub instance, the `E` type parameter should be a
* type enumerating all the available events and their payloads.
*
* @example
* type Events = {
* warn: { message: string },
* error: { message: string }
* }
*
* const pubSub = PubSub<Events>()
* const subHandle = pubSub.subscribe('warn', (message) => {
* console.warn(message)
* })
*
* pubSub.publish('warn', { message: "Something bad happened!" })
* pubSub.unsubscribe('warn', subHandle)
*/
export function PubSub<E>(): PubSubType<E> {
// This any[] is our list of handlers functions. We don't have the
// necessary type information in here which is why it's any-typed.
const handlers: { [key: string]: any[] } = {}
return {
publish: (event, msg) => {
handlers[event].forEach(h => h(msg))
},
subscribe: (event, callback) => {
const list = handlers[event] ?? []
list.push(callback)
handlers[event] = list
return callback
},
unsubscribe: (event, callback) => {
let list = handlers[event] ?? []
list = list.filter(h => h !== callback)
handlers[event] = list
}
}
}
@crobinson42
Copy link

@sidola when using the .subscribe() there is no specific type assigned the message arg. I found that this change fixes this:

type SubTypeFn<E> = <Key extends string & keyof E>(
    event: Key,
    fn: (message: E[Key]) => void
    // fn: MessageFn<E>
) => void

Here's a playground with the implementation at the bottom

@crobinson42
Copy link

Ahh, however, this changes the pattern for passing a reference to the callback for unsubscribe() calls. Perhaps returning a subscription id from the subscribe(event, callback): string would work for calling unsubscribe(subscriptionId)?

@crobinson42
Copy link

This is a solution, manually typing the SubscribeFn:
image

@sidola
Copy link
Author

sidola commented May 14, 2022

@crobinson42 Hey, thanks for the heads up, it seems I broke it in revision 3. I've fixed it again now.

The problem was extracting a fresh key inside MessageFn, like so:

type MessageFn<E> = <Key extends string & keyof E>(message: E[Key]) => void

This meant we lost the key information when using this type in SubTypeFn and instead got a funky union-type param. The fix seems indeed to be to drop the MessageFn type and just derive everything where we need it.

This means we need to make our handlers array any[] typed however, since we obviously won't have the key info there, but that's fine, we don't need the type of our handlers.

To solve the unsubscribe problem you identified, I opted to have subscribe return the handler given to it. This gives us a reference to use when calling unsubscribe, and as an added bonus we get some type safety in the form of this:

const subHandle = pubSub.subscribe('error', ({message}) => {
    console.warn(message)
})

// Allowed, is an 'error' handler
pubSub.unsubscribe('error', subHandle)
// Error, is not a 'warn' handler
pubSub.unsubscribe('warn', subHandle)

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