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
55 changes: 55 additions & 0 deletions .claude/rules/preserve-type-safety-during-refactoring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
title: Preserve Type Safety During Refactoring
impact: MEDIUM
paths:
- "**/*.ts"
---

# Preserve Type Safety During Refactoring

Count `as` type assertions before and after any refactoring. The refactored code must have equal or fewer assertions than the original. Increasing assertion count indicates the new abstractions fight the type system rather than working with it.

## Incorrect

Merging two typed functions into one with an `unknown` parameter, requiring new `as` casts at every usage site.

```typescript
// Original: 2 functions, 0 extra assertions
const handleSuccess = <R>(result: R, hooks: Hooks<R>): R => {
return hooks.onReturn ? hooks.onReturn(result) : result;
};
const handleError = <R>(error: unknown, hooks: Hooks<R>): R => {
if (hooks.onError) return hooks.onError(error);
throw error;
};

// Refactored: 1 function, but 3 new assertions
const settle = <R>(succeeded: boolean, value: unknown, hooks: Hooks<R>): R => {
if (succeeded) {
return hooks.onReturn
? (hooks.onReturn(value as R) as R) // 2 new assertions
: (value as R); // 1 new assertion
}
// ...
};
```

## Correct

Use overloads or a discriminated union to preserve type information without additional casts.

```typescript
type Outcome<R> =
| { succeeded: true; value: R }
| { succeeded: false; error: unknown };

const settle = <R>(outcome: Outcome<R>, hooks: Hooks<R>): R => {
if (outcome.succeeded) {
return hooks.onReturn
? hooks.onReturn(outcome.value) // no cast needed, value is R
: outcome.value;
}
if (hooks.onError) return hooks.onError(outcome.error);
throw outcome.error;
};
```
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,64 @@ class Service {
}
```

### Async hooks

When the decorated method returns a `Promise`, all hooks may optionally return a `Promise` as well. `onReturn` receives the **unwrapped** resolved value, and the library automatically chains the returned promise so async hooks execute in the correct order.

```typescript
import { Effect } from 'base-decorators';

const DelayedLog = () => Effect({
// Returning a Promise delays the original method
onInvoke: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
console.log('starting...');
},

// `result` is the unwrapped string, not Promise<string>
onReturn: async ({ result }) => {
console.log('done:', result);
return `${result} (modified)`;
},
});

class Service {
@DelayedLog()
async greet(name: string): Promise<string> {
return `Hello, ${name}`;
}
}

const service = new Service();
const value = await service.greet('World');
// "Hello, World (modified)"
```

Async `onError` and `finally` hooks work the same way. Here, an async `finally` flushes a log buffer after every call:

```typescript
import { Effect } from 'base-decorators';

const buffer: string[] = [];

const FlushAfterCall = () => Effect({
onInvoke: ({ args }) => {
buffer.push('invoke: ' + String(args));
},
finally: async () => {
await fetch('/log', { method: 'POST', body: JSON.stringify(buffer) });
buffer.length = 0;
},
});

class Worker {
@FlushAfterCall()
async doWork(id: number): Promise<void> {
// async work
}
}
```

### Class and Method decorators

`Effect` and all hook decorators can be used on both classes and methods out of the box.
Expand Down
Loading
Loading