Skip to content

Instantly share code, notes, and snippets.

@Akryum
Created August 22, 2021 13:49
Show Gist options
  • Save Akryum/c18f10e16a24b86dcb2c9c11cdf6fc47 to your computer and use it in GitHub Desktop.
Save Akryum/c18f10e16a24b86dcb2c9c11cdf6fc47 to your computer and use it in GitHub Desktop.
Type-safe hooks
import { Hookable } from '../src/hookable'
describe('hookable', () => {
test('hook with one callback', async () => {
const hooks = new Hookable<{
invert:(value: boolean) => boolean | Promise<boolean>
}>()
hooks.hook('invert', value => !value)
const res = await hooks.callHook('invert', true)
expect(res).toBe(false)
})
test('hook with multiple callbacks', async () => {
const hooks = new Hookable<{
increment:(value: number) => number | Promise<number>
}>()
hooks.hook('increment', value => value + 1)
hooks.hook('increment', async value => Promise.resolve(value + 1))
hooks.hook('increment', value => value + 2)
const res = await hooks.callHook('increment', 0)
expect(res).toBe(4)
})
test('multiple hooks', async () => {
const hooks = new Hookable<{
decrement:(value: number) => number | Promise<number>
increment:(value: number) => number | Promise<number>
}>()
hooks.hook('increment', value => value + 1)
hooks.hook('decrement', value => value - 1)
let res = await hooks.callHook('increment', 0)
res = await hooks.callHook('decrement', res)
expect(res).toBe(0)
})
test('hook with multiple parameters', async () => {
const hooks = new Hookable<{
increment:(value: number, count?: number) => number
}>()
hooks.hook('increment', (value, count) => value + (count ?? 1))
expect(await hooks.callHook('increment', 0)).toBe(1)
expect(await hooks.callHook('increment', 0, 2)).toBe(2)
})
})
type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T
export type HookMap<THooks> = Partial<Record<keyof THooks, THooks[keyof THooks][]>>
export class Hookable<THooks extends Record<string, (...args: any[]) => any>> {
_hooks: HookMap<THooks>
constructor () {
this._hooks = {}
}
/**
* Register a callback to a specific hook.
*/
hook<Key extends keyof THooks> (name: Key, fn: THooks[Key]): void {
if (!name || typeof fn !== 'function') {
return
}
this._hooks[name] = this._hooks[name] || []
this._hooks[name].push(fn)
}
/**
* Call all callbacks for a hook. They are called sequentially with async support.
*/
async callHook<Key extends keyof THooks> (
name: Key,
...args: Parameters<THooks[Key]>
): Promise<Awaited<ReturnType<THooks[Key]>>> {
if (!this._hooks[name]) {
return args[0]
}
try {
let result: Awaited<ReturnType<THooks[Key]>> = args[0]
for (const fn of this._hooks[name]) {
const returned = await fn(result, ...args.slice(1))
if (typeof returned !== 'undefined') {
result = returned
}
}
return result
} catch (err) {
// @ts-expect-error The 'error' hook might not exist in THooks
name !== 'error' && await this.callHook('error', err)
console.error(err)
throw err
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment