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
3 changes: 2 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"Bash(npx vitest typecheck *)",
"Bash(npx vitepress *)",
"Bash(npx prettier *)",
"Bash(pnpm test)"
"Bash(pnpm test)",
"Bash(npx eslint *)"
]
}
}
40 changes: 40 additions & 0 deletions docs/migrating-from-feathers-hooks-common.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,46 @@ The old `cache` hook only worked for `get` requests and did not work with varyin

The new `cache` hook caches `get` and `find` requests and considers the `params` object when caching. This means that if you call the same `get` or `find` request with different `params`, it will cache each unique request separately.

## `checkContext`

The `checkContext` utility has been updated with an options object syntax and now supports **TypeScript type narrowing**. After calling `checkContext`, the compiler automatically narrows `context.type`, `context.method`, and `context.path` based on the options you pass.

The old positional arguments still work but the options object is recommended:

```ts
// old
import { checkContext } from "feathers-hooks-common";

checkContext(context, "before", ["create", "patch"], "myHook");

// new (recommended: options object with type narrowing)
import { checkContext } from "feathers-utils/utils";

checkContext(context, {
type: "before",
method: ["create", "patch"],
label: "myHook",
});

// After checkContext, TypeScript narrows the types:
context.type; // 'before'
context.method; // 'create' | 'patch'
```

You can also narrow by `path`, which was not available in the old API:

```ts
checkContext(context, {
type: ["before", "around"],
method: ["create", "patch"],
path: "users",
});

context.type; // 'before' | 'around'
context.method; // 'create' | 'patch'
context.path; // 'users'
```

## `callingParams`

The `callingParams` utility was removed. If you need it please reach out to us in this [github issue](https://github.com/feathersjs/feathers-utils/issues/1).
Expand Down
1 change: 1 addition & 0 deletions src/internal.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const hasOwnProperty = (
}

export type MaybeArray<T> = T | readonly T[]
export type UnpackMaybeArray<T> = T extends readonly (infer E)[] ? E : T
export const toArray = <T>(value: T | readonly T[]): T[] =>
Array.isArray(value) ? [...value] : [value as T]

Expand Down
97 changes: 77 additions & 20 deletions src/utils/check-context/check-context.util.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,51 +22,108 @@ type App = typeof app
type AppCtx = HookContext<App>
type UserCtx = HookContext<App, MemoryService<User>>

const context = {} as UserCtx
const appContext = {} as AppCtx

it('options overload accepts valid options', () => {
checkContext(context, { type: 'before' })
checkContext(context, { method: 'create' })
checkContext(context, {
const ctx1 = {} as UserCtx
checkContext(ctx1, { type: 'before' })
const ctx2 = {} as UserCtx
checkContext(ctx2, { method: 'create' })
const ctx3 = {} as UserCtx
checkContext(ctx3, {
type: ['before', 'around'],
method: ['create', 'patch'],
})
checkContext(context, { type: 'before', label: 'myHook' })
checkContext(context, { path: 'users' })
const ctx4 = {} as UserCtx
checkContext(ctx4, { type: 'before', label: 'myHook' })
const ctx5 = {} as UserCtx
checkContext(ctx5, { path: 'users' })
})

it('options overload rejects invalid type', () => {
const ctx = {} as UserCtx
// @ts-expect-error "invalid" is not a valid HookType
checkContext(context, { type: 'invalid' })
checkContext(ctx, { type: 'invalid' })
})

it('options overload accepts valid path for app-level context', () => {
checkContext(appContext, { path: 'users' })
checkContext(appContext, { path: 'messages' })
checkContext(appContext, { path: ['users', 'messages'] })
const ctx1 = {} as AppCtx
checkContext(ctx1, { path: 'users' })
const ctx2 = {} as AppCtx
checkContext(ctx2, { path: 'messages' })
const ctx3 = {} as AppCtx
checkContext(ctx3, { path: ['users', 'messages'] })
})

it('options overload rejects invalid path for service-specific context', () => {
const ctx = {} as UserCtx
// @ts-expect-error "messages" is not valid when context is narrowed to MemoryService<User>
checkContext(context, { path: 'messages' })
checkContext(ctx, { path: 'messages' })
})

it('options overload rejects invalid path for app-level context', () => {
const ctx = {} as AppCtx
// @ts-expect-error "nonExistent" is not a valid service path
checkContext(appContext, { path: 'nonExistent' })
checkContext(ctx, { path: 'nonExistent' })
})

it('positional overload accepts valid args', () => {
checkContext(context, 'before')
checkContext(context, ['before', 'after'])
checkContext(context, 'before', 'create')
checkContext(context, ['before', 'around'], ['create', 'patch'], 'myHook')
checkContext(context, null, 'create')
checkContext(context, undefined, 'create')
const ctx1 = {} as UserCtx
checkContext(ctx1, 'before')
const ctx2 = {} as UserCtx
checkContext(ctx2, ['before', 'after'])
const ctx3 = {} as UserCtx
checkContext(ctx3, 'before', 'create')
const ctx4 = {} as UserCtx
checkContext(ctx4, ['before', 'around'], ['create', 'patch'], 'myHook')
const ctx5 = {} as UserCtx
checkContext(ctx5, null, 'create')
const ctx6 = {} as UserCtx
checkContext(ctx6, undefined, 'create')
})

it('positional overload rejects invalid type', () => {
// @ts-expect-error "invalid" is not a valid HookType
checkContext(context, 'invalid')
})

it('narrows path with options overload', () => {
const ctx = {} as AppCtx
checkContext(ctx, { path: ['users', 'messages'] })
expectTypeOf(ctx.path).toEqualTypeOf<'users' | 'messages'>()
})

it('narrows path with single value', () => {
const ctx = {} as AppCtx
checkContext(ctx, { path: 'users' })
expectTypeOf(ctx.path).toEqualTypeOf<'users'>()
})

it('narrows type with options overload', () => {
const ctx = {} as AppCtx
checkContext(ctx, { type: ['before', 'around'] })
expectTypeOf(ctx.type).toEqualTypeOf<'before' | 'around'>()
})

it('narrows method with options overload', () => {
const ctx = {} as AppCtx
checkContext(ctx, { method: ['create', 'patch'] })
expectTypeOf(ctx.method).toEqualTypeOf<'create' | 'patch'>()
})

it('narrows type with positional overload', () => {
const ctx = {} as AppCtx
checkContext(ctx, 'before')
expectTypeOf(ctx.type).toEqualTypeOf<'before'>()
})

it('narrows type and method with positional overload', () => {
const ctx = {} as AppCtx
checkContext(ctx, ['before', 'around'], ['create', 'patch'])
expectTypeOf(ctx.type).toEqualTypeOf<'before' | 'around'>()
expectTypeOf(ctx.method).toEqualTypeOf<'create' | 'patch'>()
})

it('does not narrow when null is passed in positional overload', () => {
const ctx = {} as AppCtx
checkContext(ctx, null, 'create')
expectTypeOf(ctx.method).toEqualTypeOf<'create'>()
})
20 changes: 18 additions & 2 deletions src/utils/check-context/check-context.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,29 @@ describe('util checkContext', () => {
type: 'before',
label: 'myHook',
}),
).toThrow("The 'myHook' hook has invalid context.")
).toThrow(
"The 'myHook' hook has invalid context (type: expected 'before' but got 'after').",
)
})

it('uses default label when not provided', () => {
expect(() =>
checkContext(make('after', 'create'), { type: 'before' }),
).toThrow("The 'anonymous' hook has invalid context.")
).toThrow(
"The 'anonymous' hook has invalid context (type: expected 'before' but got 'after').",
)
})

it('shows multiple mismatches in error message', () => {
expect(() =>
checkContext(make('after', 'patch'), {
type: ['before', 'around'],
method: 'create',
label: 'myHook',
}),
).toThrow(
"The 'myHook' hook has invalid context (type: expected 'before' | 'around' but got 'after', method: expected 'create' but got 'patch').",
)
})
})
})
63 changes: 53 additions & 10 deletions src/utils/check-context/check-context.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ import {
isContext,
type IsContextOptions,
} from '../../predicates/is-context/is-context.predicate.js'
import type { UnpackMaybeArray } from '../../internal.utils.js'

type NarrowedContext<H extends HookContext, O> = H &
(O extends { path: infer P }
? [P] extends [undefined | null]
? unknown
: { path: UnpackMaybeArray<P> }
: unknown) &
(O extends { type: infer T }
? [T] extends [undefined | null]
? unknown
: { type: UnpackMaybeArray<T> }
: unknown) &
(O extends { method: infer M }
? [M] extends [undefined | null]
? unknown
: { method: UnpackMaybeArray<M> }
: unknown)

export type CheckContextOptions<H extends HookContext = HookContext> =
IsContextOptions<H> & {
Expand All @@ -14,6 +32,7 @@ export type CheckContextOptions<H extends HookContext = HookContext> =
* Validates that the hook context matches the expected type(s) and method(s).
* Throws an error if the context is invalid, preventing hooks from running in
* unsupported configurations. Typically used internally by other hooks.
* Also narrows the context type based on the passed options.
*
* @example
* ```ts
Expand All @@ -23,22 +42,26 @@ export type CheckContextOptions<H extends HookContext = HookContext> =
* checkContext(context, ['before', 'around'], ['create', 'patch'], 'myHook')
* // or with options object:
* checkContext(context, { type: ['before', 'around'], method: ['create', 'patch'], label: 'myHook' })
* // ... hook logic
* // context.type is now 'before' | 'around', context.method is now 'create' | 'patch'
* }
* ```
*
* @see https://utils.feathersjs.com/utils/check-context.html
*/
export function checkContext<H extends HookContext = HookContext>(
export function checkContext<
H extends HookContext,
const O extends CheckContextOptions<NoInfer<H>>,
>(context: H, options: O): asserts context is NarrowedContext<H, O>
export function checkContext<
H extends HookContext,
const T extends HookType | HookType[] | null | undefined = undefined,
const M extends MethodName | MethodName[] | null | undefined = undefined,
>(
context: H,
options: CheckContextOptions<NoInfer<H>>,
): void
export function checkContext<H extends HookContext = HookContext>(
context: H,
type?: HookType | HookType[] | null,
methods?: MethodName | MethodName[] | null,
type?: T,
methods?: M,
label?: string,
): void
): asserts context is NarrowedContext<H, { type: T; method: M }>
export function checkContext<H extends HookContext = HookContext>(
context: H,
typeOrOptions?:
Expand Down Expand Up @@ -69,6 +92,26 @@ export function checkContext<H extends HookContext = HookContext>(
}

if (!isContext(options)(context)) {
throw new Error(`The '${hookLabel}' hook has invalid context.`)
const details: string[] = []

if (options.type != null) {
details.push(
`type: expected '${Array.isArray(options.type) ? options.type.join("' | '") : options.type}' but got '${context.type}'`,
)
}
if (options.method != null) {
details.push(
`method: expected '${Array.isArray(options.method) ? options.method.join("' | '") : options.method}' but got '${context.method}'`,
)
}
if (options.path != null) {
details.push(
`path: expected '${Array.isArray(options.path) ? options.path.join("' | '") : options.path}' but got '${context.path}'`,
)
}

throw new Error(
`The '${hookLabel}' hook has invalid context (${details.join(', ')}).`,
)
}
}
Loading