Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .claude/rules/type-level-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: Verify Type Changes With Compile-Time Tests
impact: HIGH
paths:
- "tests/**/*.ts"
- "src/**/*.ts"
---

# Verify Type Changes With Compile-Time Tests

When adding or modifying generic type parameters, include compile-time type verification tests using `expectTypeOf` (from vitest) or `@ts-expect-error` comments. Runtime assertions alone cannot detect silent type degradation where generics fall back to defaults like `unknown`.

## Incorrect

Type tests that only use runtime assertions -- these pass even if generics silently degrade to `unknown`.

```typescript
it('HookContext accepts generic parameters', () => {
const ctx: HookContext<MyService, [number], string> = { /* ... */ };
// This runtime assertion passes regardless of whether generics work
expect(ctx.target).toBeDefined();
expect(ctx.args).toEqual([42]);
});
```

## Correct

Type tests that include compile-time verification and negative cases.

```typescript
import { expectTypeOf } from 'vitest';

it('HookContext infers target type from generic parameter', () => {
const ctx: HookContext<MyService, [number], string> = { /* ... */ };

// Positive: verify inferred types at compile time
expectTypeOf(ctx.target).toEqualTypeOf<MyService>();
expectTypeOf(ctx.args).toEqualTypeOf<[number]>();

// Negative: verify wrong types are rejected
// @ts-expect-error - target should not accept string
const bad: HookContext<string> = { /* ... */ };
});
```
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ This project uses **semantic-release** with **Conventional Commits**. Follow the

**Core flow:**

1. `Effect` / `EffectOnMethod` / `EffectOnClass` (src/) — Logger-agnostic decorator primitives. `EffectOnMethod` wraps a single method: extracts parameter names, builds a `HookContext` (args object, target, propertyKey, descriptor, parameterNames, className), and invokes lifecycle hooks. `EffectOnClass` iterates prototype methods and applies `EffectOnMethod` to each. `Effect` dispatches to one or the other based on argument count.
3. `buildArgsObject` (src/effect-on-method.ts) — Maps parameter names to their call-time values to produce the pre-built `args` object passed in every `HookContext`.
1. `Wrap` / `WrapOnMethod` / `WrapOnClass` (src/) — Foundational decorator primitives. `WrapOnMethod` wraps a single method with lazy initialization: the factory runs once on first invocation with a method proxy and `WrapContext`. `WrapOnClass` iterates prototype methods and applies `WrapOnMethod` to each. `Wrap` dispatches to one or the other based on argument count.
2. `Effect` (src/effect.decorator.ts) — Higher-level abstraction built on `Wrap` that provides lifecycle hooks (onInvoke, onReturn, onError, finally). Builds a `HookContext` (args, argsObject, target, propertyKey, descriptor, parameterNames, className) per invocation.
3. `buildArgsObject` (src/effect.decorator.ts) — Maps parameter names to their call-time values to produce the pre-built `argsObject` passed in every `HookContext`.
68 changes: 29 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,17 @@ npm install base-decorators

### Using Wrap

`Wrap` is the foundational primitive. You receive a `WrapContext` at decoration time and return a replacement function:
`Wrap` is the foundational primitive. You receive the original method and a `WrapContext` on first invocation and return a replacement function:

```typescript
import { Wrap } from 'base-decorators';
import type { WrapContext, InvocationContext } from 'base-decorators';
import type { WrapContext } from 'base-decorators';

const Log = () => Wrap((context: WrapContext) => {
// Outer function: called once at decoration time
const Log = () => Wrap((method, context: WrapContext) => {
// Outer function: called once on first invocation
console.log('decorating', context.propertyKey);

return (method, {args}) => {
return (...args) => {
// Inner function: called on every invocation
console.log('called with', args);

Expand All @@ -77,10 +77,10 @@ class Calculator {
return a + b;
}
}
// logs: "decorating add" (at decoration time)

const calc = new Calculator();
calc.add(2, 3);
// logs: "decorating add" (first time)
// logs: "called with [2, 3]"
// logs: "returned 5"

Expand Down Expand Up @@ -114,22 +114,24 @@ calc.add(2, 3);

## How It Works

**`Wrap`** accepts a factory function that receives a `WrapContext` at decoration time and returns an inner function. The inner function is called on every invocation with the `this`-bound original method and an `InvocationContext`. You control the entire execution flow:
**`Wrap`** accepts a factory function that receives the original method and a `WrapContext` on first invocation and returns an inner function. The inner function is called on every invocation with the raw arguments. You control the entire execution flow:

```typescript
import { Wrap } from 'base-decorators';
import type { WrapContext, InvocationContext } from 'base-decorators';
import type { WrapContext } from 'base-decorators';

const Log = () => Wrap((context: WrapContext) => {
// Outer: called once at decoration time. WrapContext has propertyKey, parameterNames, descriptor.
return (method, { args, className }: InvocationContext) => {
// Inner: called on every invocation. InvocationContext extends WrapContext with target, className, args, argsObject.
console.log(`${className}.${String(context.propertyKey)} called`);
const Log = () => Wrap((method, context: WrapContext) => {
// Outer: called once on first invocation.
return (...args) => {
// Inner: called on every invocation with raw arguments.
console.log(`${context.className}.${String(context.propertyKey)} called`);
return method(...args);
};
});
```

> **Auto-bound method:** The `method` parameter is automatically bound to the current `this` instance on every call. You never need to use `.bind()`, `.call()`, or `.apply()` -- just invoke `method(...args)` directly and it will execute with the correct `this` context.

**`Effect`**: Instead of writing the full wrapping logic yourself, you provide lifecycle hooks and Effect handles the execution flow:

```typescript
Expand Down Expand Up @@ -216,10 +218,10 @@ All decorators work naturally with async methods. Return an async inner function

```typescript
import { Wrap } from 'base-decorators';
import type { WrapContext, InvocationContext } from 'base-decorators';
import type { WrapContext } from 'base-decorators';

const AsyncTimer = () => Wrap((context: WrapContext) => {
return async (method, { args }) => {
const AsyncTimer = () => Wrap((method, context: WrapContext) => {
return async (...args) => {
const start = Date.now();
const result = await method(...args);

Expand Down Expand Up @@ -420,38 +422,27 @@ Each hook receives a context object. All hooks are optional. Each hook has a cor

### WrapContext

Available in the **outer** factory function passed to `Wrap`. Contains only decoration-time fields -- fields that vary per call (target, className, args) are absent here.
Passed to the **outer** factory function of `Wrap` on first invocation. Contains decoration-time fields plus mutable runtime fields (`target`, `className`) that update before each call.

```typescript
interface WrapContext {
propertyKey: string | symbol; // method name
parameterNames: string[]; // extracted parameter names
descriptor: PropertyDescriptor; // method descriptor
target: object; // class instance (this), updated per call
className: string; // runtime class name, updated per call
}
```

### InvocationContext
### HookContext

Passed to the **inner** function returned by your `Wrap` factory. Extends `WrapContext` with per-call runtime fields, so all decoration-time fields are also available here.
Passed to every `Effect` lifecycle hook. Extends `WrapContext` with per-call argument data.

```typescript
interface InvocationContext extends WrapContext {
target: object; // class instance (this)
className: string; // runtime class name
interface HookContext extends WrapContext {
args: unknown[]; // raw arguments
argsObject: Record<string, unknown> | undefined; // mapped parameter names
// Plus all WrapContext fields: propertyKey, parameterNames, descriptor
}
```

### HookContext

Passed to every `Effect` lifecycle hook. Equivalent to `InvocationContext` -- all seven fields are available.

```typescript
interface HookContext extends InvocationContext {
// All fields from WrapContext: propertyKey, parameterNames, descriptor
// All fields from InvocationContext: target, className, args, argsObject
// Plus all WrapContext fields: propertyKey, parameterNames, descriptor, target, className
}
```

Expand Down Expand Up @@ -502,7 +493,7 @@ class DebugService {
}
```

The factory is called **once at decoration time** with the `WrapContext` containing `propertyKey`, `parameterNames`, and `descriptor`. The resolved hooks are reused for every subsequent call. Each resolved hook still receives the full `HookContext` (including `args`, `argsObject`, `target`, and `className`) on every invocation.
The factory is called **once on first invocation** with the `WrapContext` containing `propertyKey`, `parameterNames`, `descriptor`, `target`, and `className`. The resolved hooks are reused for every subsequent call. Each resolved hook still receives the full `HookContext` (including `args` and `argsObject`) on every invocation.

## API Reference

Expand All @@ -517,10 +508,9 @@ The factory is called **once at decoration time** with the `WrapContext` contain
| `OnReturnHook` | Decorator | Convenience hook for `onReturn` |
| `OnErrorHook` | Decorator | Convenience hook for `onError` |
| `FinallyHook` | Decorator | Convenience hook for `finally` |
| `WrapContext` | Type | Decoration-time context passed to `Wrap` outer factory (propertyKey, parameterNames, descriptor) |
| `InvocationContext` | Type | Per-call context extending `WrapContext` with runtime fields (target, className, args, argsObject) |
| `WrapFn` | Type | Wrapper function signature: `(context: WrapContext) => (method, context: InvocationContext) => R` |
| `HookContext` | Type | Context passed to `Effect` hooks -- equivalent to `InvocationContext` with all fields |
| `WrapContext` | Type | Context passed to `Wrap` factory (propertyKey, parameterNames, descriptor, target, className) |
| `WrapFn` | Type | Wrapper function signature: `(method, context: WrapContext) => (...args) => R` |
| `HookContext` | Type | Context passed to `Effect` hooks -- extends `WrapContext` with args, argsObject |
| `EffectHooks` | Type | Lifecycle hooks object for `Effect` (onInvoke, onReturn, onError, finally) |

## Advanced Example
Expand Down
Loading
Loading