Skip to content

HookEngine.add() rejects PipeCallback, so pipe hooks are untypeable #147

Description

@damusix

Summary

HookEngine.add() is typed only for run()-style hooks, so pipe middleware cannot be registered without a cast or @ts-expect-error. The exported PipeCallback type — which describes the correct shape — is rejected by add.

Repro

  1. npm install @logosdx/hooks@1.0.3 typescript@5.9
  2. Create repro.ts:
import { HookEngine, type PipeCallback } from '@logosdx/hooks';

interface Lifecycle {
    execute(opts: { url: string }): Promise<string>;
}

const hooks = new HookEngine<Lifecycle>();

hooks.register('execute');

// The exported PipeCallback describes the correct shape...
const middleware: PipeCallback<[opts: { url: string }], string> = async (next, opts, ctx) => {
    return next();
};

// ...but `add` will not accept it.
hooks.add('execute', middleware);
  1. npx tsc --noEmit --strict --module nodenext --moduleResolution nodenext --target es2022 repro.ts

Expected

add accepts pipe middleware for a pipe hook — the shape PipeCallback already describes, and the shape the add JSDoc itself demonstrates (hooks.add('execute', async (next, opts, ctx) => …)).

Actual

repro.ts(17,22): error TS2345: Argument of type 'PipeCallback<[opts: { url: string; }], string>' is not assignable to parameter of type '(opts: { url: string; }, args_1: HookContext<[opts: { url: string; }], string, [string]>) => void | unique symbol | Promise<void | unique symbol>'.
  Target signature provides too few arguments. Expected 3 or more, but got 2.

Writing the callback inline as the JSDoc shows fails the same way, plus TS7006 on every parameter:

repro.ts(13,22): error TS2345: Argument of type '(next: any, opts: any, ctx: any) => Promise<any>' is not assignable to parameter of type '(opts: { url: string; }, args_1: HookContext<[opts: { url: string; }], string, [string]>) => void | unique symbol | Promise<void | unique symbol>'.
  Target signature provides too few arguments. Expected 3 or more, but got 2.
repro.ts(13,29): error TS7006: Parameter 'next' implicitly has an 'any' type.
repro.ts(13,35): error TS7006: Parameter 'opts' implicitly has an 'any' type.
repro.ts(13,41): error TS7006: Parameter 'ctx' implicitly has an 'any' type.

Root cause

HookEngine has two invocation modes but one registration method:

  • run() — callbacks are (...args, ctx) returning void | EarlyReturnSignal
  • pipe() — middleware is (next, ...args, ctx) and must return the response

add (index.d.ts:318) types every callback with HookCallback, the run shape. Pipe middleware violates it twice: the leading next breaks arity, and returning a value violates void | EarlyReturnSignal. No cast-free structural workaround exists — widening the engine to HookEngine<any> tightens the constraint rather than relaxing it.

PipeCallback (index.d.ts:231) is exported and referenced nowhere else in the package.

Impact

@logosdx/fetch declares execute as a pipe hook in FetchLifecycle and invokes it via hooks.pipe('execute', …) in its executor. Any FetchPlugin that wraps execute — retry, auth-refresh, replay — must suppress or cast to compile.

Possible fix

Type-only and non-breaking: add a sibling method delegating to the same registry, since pipe() already invokes whatever add() stored.

addPipe<K extends HookName<Lifecycle>>(
    name: K,
    callback: PipeCallback<
        Parameters<FuncOrNever<Lifecycle[K]>>,
        Awaited<ReturnType<FuncOrNever<Lifecycle[K]>>>,
        FailArgs
    >,
    options?: HookEngine.AddOptions,
): () => void;

An add overload is worse: a run-hook with a wrong signature would silently match the pipe overload and produce a more confusing error.

The principled alternative is marking pipe hooks in the lifecycle type itself (e.g. a branded Pipe<(opts) => Response>) so add selects the callback type automatically — that also makes the inverse mistake, pipe('beforeRequest', …), a type error. It requires every consumer lifecycle to annotate.

Related

  • pipe() takes ...args: unknown[] with R unbound, so its arguments are not checked against the lifecycle signature.
  • PipeOptions.append is (...args: any[]) => any.

Environment

  • @logosdx/hooks: 1.0.3 (also reproduces on 1.0.0)
  • TypeScript: 5.9
  • Node: 24.13.0
  • OS: macOS (darwin arm64)

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions