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
npm install @logosdx/hooks@1.0.3 typescript@5.9
- 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);
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)
Summary
HookEngine.add()is typed only forrun()-style hooks, so pipe middleware cannot be registered without a cast or@ts-expect-error. The exportedPipeCallbacktype — which describes the correct shape — is rejected byadd.Repro
npm install @logosdx/hooks@1.0.3 typescript@5.9repro.ts:npx tsc --noEmit --strict --module nodenext --moduleResolution nodenext --target es2022 repro.tsExpected
addaccepts pipe middleware for a pipe hook — the shapePipeCallbackalready describes, and the shape theaddJSDoc itself demonstrates (hooks.add('execute', async (next, opts, ctx) => …)).Actual
Writing the callback inline as the JSDoc shows fails the same way, plus
TS7006on every parameter:Root cause
HookEnginehas two invocation modes but one registration method:run()— callbacks are(...args, ctx)returningvoid | EarlyReturnSignalpipe()— middleware is(next, ...args, ctx)and must return the responseadd(index.d.ts:318) types every callback withHookCallback, therunshape. Pipe middleware violates it twice: the leadingnextbreaks arity, and returning a value violatesvoid | EarlyReturnSignal. No cast-free structural workaround exists — widening the engine toHookEngine<any>tightens the constraint rather than relaxing it.PipeCallback(index.d.ts:231) is exported and referenced nowhere else in the package.Impact
@logosdx/fetchdeclaresexecuteas a pipe hook inFetchLifecycleand invokes it viahooks.pipe('execute', …)in its executor. AnyFetchPluginthat wrapsexecute— 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 whateveradd()stored.An
addoverload 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>) soaddselects 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[]withRunbound, so its arguments are not checked against the lifecycle signature.PipeOptions.appendis(...args: any[]) => any.Environment
@logosdx/hooks: 1.0.3 (also reproduces on 1.0.0)