Skip to content

Commit 66d74ea

Browse files
authored
feat: checkContext type narrowing (#28)
* feat: checkContext type narrowing * fix: checkContext better error message
1 parent abaa90b commit 66d74ea

File tree

6 files changed

+191
-33
lines changed

6 files changed

+191
-33
lines changed

.claude/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"Bash(npx vitest typecheck *)",
77
"Bash(npx vitepress *)",
88
"Bash(npx prettier *)",
9-
"Bash(pnpm test)"
9+
"Bash(pnpm test)",
10+
"Bash(npx eslint *)"
1011
]
1112
}
1213
}

docs/migrating-from-feathers-hooks-common.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,46 @@ The old `cache` hook only worked for `get` requests and did not work with varyin
4646

4747
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.
4848

49+
## `checkContext`
50+
51+
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.
52+
53+
The old positional arguments still work but the options object is recommended:
54+
55+
```ts
56+
// old
57+
import { checkContext } from "feathers-hooks-common";
58+
59+
checkContext(context, "before", ["create", "patch"], "myHook");
60+
61+
// new (recommended: options object with type narrowing)
62+
import { checkContext } from "feathers-utils/utils";
63+
64+
checkContext(context, {
65+
type: "before",
66+
method: ["create", "patch"],
67+
label: "myHook",
68+
});
69+
70+
// After checkContext, TypeScript narrows the types:
71+
context.type; // 'before'
72+
context.method; // 'create' | 'patch'
73+
```
74+
75+
You can also narrow by `path`, which was not available in the old API:
76+
77+
```ts
78+
checkContext(context, {
79+
type: ["before", "around"],
80+
method: ["create", "patch"],
81+
path: "users",
82+
});
83+
84+
context.type; // 'before' | 'around'
85+
context.method; // 'create' | 'patch'
86+
context.path; // 'users'
87+
```
88+
4989
## `callingParams`
5090

5191
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).

src/internal.utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const hasOwnProperty = (
88
}
99

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

src/utils/check-context/check-context.util.test-d.ts

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,51 +22,108 @@ type App = typeof app
2222
type AppCtx = HookContext<App>
2323
type UserCtx = HookContext<App, MemoryService<User>>
2424

25-
const context = {} as UserCtx
26-
const appContext = {} as AppCtx
27-
2825
it('options overload accepts valid options', () => {
29-
checkContext(context, { type: 'before' })
30-
checkContext(context, { method: 'create' })
31-
checkContext(context, {
26+
const ctx1 = {} as UserCtx
27+
checkContext(ctx1, { type: 'before' })
28+
const ctx2 = {} as UserCtx
29+
checkContext(ctx2, { method: 'create' })
30+
const ctx3 = {} as UserCtx
31+
checkContext(ctx3, {
3232
type: ['before', 'around'],
3333
method: ['create', 'patch'],
3434
})
35-
checkContext(context, { type: 'before', label: 'myHook' })
36-
checkContext(context, { path: 'users' })
35+
const ctx4 = {} as UserCtx
36+
checkContext(ctx4, { type: 'before', label: 'myHook' })
37+
const ctx5 = {} as UserCtx
38+
checkContext(ctx5, { path: 'users' })
3739
})
3840

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

4447
it('options overload accepts valid path for app-level context', () => {
45-
checkContext(appContext, { path: 'users' })
46-
checkContext(appContext, { path: 'messages' })
47-
checkContext(appContext, { path: ['users', 'messages'] })
48+
const ctx1 = {} as AppCtx
49+
checkContext(ctx1, { path: 'users' })
50+
const ctx2 = {} as AppCtx
51+
checkContext(ctx2, { path: 'messages' })
52+
const ctx3 = {} as AppCtx
53+
checkContext(ctx3, { path: ['users', 'messages'] })
4854
})
4955

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

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

6068
it('positional overload accepts valid args', () => {
61-
checkContext(context, 'before')
62-
checkContext(context, ['before', 'after'])
63-
checkContext(context, 'before', 'create')
64-
checkContext(context, ['before', 'around'], ['create', 'patch'], 'myHook')
65-
checkContext(context, null, 'create')
66-
checkContext(context, undefined, 'create')
69+
const ctx1 = {} as UserCtx
70+
checkContext(ctx1, 'before')
71+
const ctx2 = {} as UserCtx
72+
checkContext(ctx2, ['before', 'after'])
73+
const ctx3 = {} as UserCtx
74+
checkContext(ctx3, 'before', 'create')
75+
const ctx4 = {} as UserCtx
76+
checkContext(ctx4, ['before', 'around'], ['create', 'patch'], 'myHook')
77+
const ctx5 = {} as UserCtx
78+
checkContext(ctx5, null, 'create')
79+
const ctx6 = {} as UserCtx
80+
checkContext(ctx6, undefined, 'create')
6781
})
6882

6983
it('positional overload rejects invalid type', () => {
7084
// @ts-expect-error "invalid" is not a valid HookType
7185
checkContext(context, 'invalid')
7286
})
87+
88+
it('narrows path with options overload', () => {
89+
const ctx = {} as AppCtx
90+
checkContext(ctx, { path: ['users', 'messages'] })
91+
expectTypeOf(ctx.path).toEqualTypeOf<'users' | 'messages'>()
92+
})
93+
94+
it('narrows path with single value', () => {
95+
const ctx = {} as AppCtx
96+
checkContext(ctx, { path: 'users' })
97+
expectTypeOf(ctx.path).toEqualTypeOf<'users'>()
98+
})
99+
100+
it('narrows type with options overload', () => {
101+
const ctx = {} as AppCtx
102+
checkContext(ctx, { type: ['before', 'around'] })
103+
expectTypeOf(ctx.type).toEqualTypeOf<'before' | 'around'>()
104+
})
105+
106+
it('narrows method with options overload', () => {
107+
const ctx = {} as AppCtx
108+
checkContext(ctx, { method: ['create', 'patch'] })
109+
expectTypeOf(ctx.method).toEqualTypeOf<'create' | 'patch'>()
110+
})
111+
112+
it('narrows type with positional overload', () => {
113+
const ctx = {} as AppCtx
114+
checkContext(ctx, 'before')
115+
expectTypeOf(ctx.type).toEqualTypeOf<'before'>()
116+
})
117+
118+
it('narrows type and method with positional overload', () => {
119+
const ctx = {} as AppCtx
120+
checkContext(ctx, ['before', 'around'], ['create', 'patch'])
121+
expectTypeOf(ctx.type).toEqualTypeOf<'before' | 'around'>()
122+
expectTypeOf(ctx.method).toEqualTypeOf<'create' | 'patch'>()
123+
})
124+
125+
it('does not narrow when null is passed in positional overload', () => {
126+
const ctx = {} as AppCtx
127+
checkContext(ctx, null, 'create')
128+
expectTypeOf(ctx.method).toEqualTypeOf<'create'>()
129+
})

src/utils/check-context/check-context.util.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,13 +164,29 @@ describe('util checkContext', () => {
164164
type: 'before',
165165
label: 'myHook',
166166
}),
167-
).toThrow("The 'myHook' hook has invalid context.")
167+
).toThrow(
168+
"The 'myHook' hook has invalid context (type: expected 'before' but got 'after').",
169+
)
168170
})
169171

170172
it('uses default label when not provided', () => {
171173
expect(() =>
172174
checkContext(make('after', 'create'), { type: 'before' }),
173-
).toThrow("The 'anonymous' hook has invalid context.")
175+
).toThrow(
176+
"The 'anonymous' hook has invalid context (type: expected 'before' but got 'after').",
177+
)
178+
})
179+
180+
it('shows multiple mismatches in error message', () => {
181+
expect(() =>
182+
checkContext(make('after', 'patch'), {
183+
type: ['before', 'around'],
184+
method: 'create',
185+
label: 'myHook',
186+
}),
187+
).toThrow(
188+
"The 'myHook' hook has invalid context (type: expected 'before' | 'around' but got 'after', method: expected 'create' but got 'patch').",
189+
)
174190
})
175191
})
176192
})

src/utils/check-context/check-context.util.ts

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ import {
44
isContext,
55
type IsContextOptions,
66
} from '../../predicates/is-context/is-context.predicate.js'
7+
import type { UnpackMaybeArray } from '../../internal.utils.js'
8+
9+
type NarrowedContext<H extends HookContext, O> = H &
10+
(O extends { path: infer P }
11+
? [P] extends [undefined | null]
12+
? unknown
13+
: { path: UnpackMaybeArray<P> }
14+
: unknown) &
15+
(O extends { type: infer T }
16+
? [T] extends [undefined | null]
17+
? unknown
18+
: { type: UnpackMaybeArray<T> }
19+
: unknown) &
20+
(O extends { method: infer M }
21+
? [M] extends [undefined | null]
22+
? unknown
23+
: { method: UnpackMaybeArray<M> }
24+
: unknown)
725

826
export type CheckContextOptions<H extends HookContext = HookContext> =
927
IsContextOptions<H> & {
@@ -14,6 +32,7 @@ export type CheckContextOptions<H extends HookContext = HookContext> =
1432
* Validates that the hook context matches the expected type(s) and method(s).
1533
* Throws an error if the context is invalid, preventing hooks from running in
1634
* unsupported configurations. Typically used internally by other hooks.
35+
* Also narrows the context type based on the passed options.
1736
*
1837
* @example
1938
* ```ts
@@ -23,22 +42,26 @@ export type CheckContextOptions<H extends HookContext = HookContext> =
2342
* checkContext(context, ['before', 'around'], ['create', 'patch'], 'myHook')
2443
* // or with options object:
2544
* checkContext(context, { type: ['before', 'around'], method: ['create', 'patch'], label: 'myHook' })
26-
* // ... hook logic
45+
* // context.type is now 'before' | 'around', context.method is now 'create' | 'patch'
2746
* }
2847
* ```
2948
*
3049
* @see https://utils.feathersjs.com/utils/check-context.html
3150
*/
32-
export function checkContext<H extends HookContext = HookContext>(
51+
export function checkContext<
52+
H extends HookContext,
53+
const O extends CheckContextOptions<NoInfer<H>>,
54+
>(context: H, options: O): asserts context is NarrowedContext<H, O>
55+
export function checkContext<
56+
H extends HookContext,
57+
const T extends HookType | HookType[] | null | undefined = undefined,
58+
const M extends MethodName | MethodName[] | null | undefined = undefined,
59+
>(
3360
context: H,
34-
options: CheckContextOptions<NoInfer<H>>,
35-
): void
36-
export function checkContext<H extends HookContext = HookContext>(
37-
context: H,
38-
type?: HookType | HookType[] | null,
39-
methods?: MethodName | MethodName[] | null,
61+
type?: T,
62+
methods?: M,
4063
label?: string,
41-
): void
64+
): asserts context is NarrowedContext<H, { type: T; method: M }>
4265
export function checkContext<H extends HookContext = HookContext>(
4366
context: H,
4467
typeOrOptions?:
@@ -69,6 +92,26 @@ export function checkContext<H extends HookContext = HookContext>(
6992
}
7093

7194
if (!isContext(options)(context)) {
72-
throw new Error(`The '${hookLabel}' hook has invalid context.`)
95+
const details: string[] = []
96+
97+
if (options.type != null) {
98+
details.push(
99+
`type: expected '${Array.isArray(options.type) ? options.type.join("' | '") : options.type}' but got '${context.type}'`,
100+
)
101+
}
102+
if (options.method != null) {
103+
details.push(
104+
`method: expected '${Array.isArray(options.method) ? options.method.join("' | '") : options.method}' but got '${context.method}'`,
105+
)
106+
}
107+
if (options.path != null) {
108+
details.push(
109+
`path: expected '${Array.isArray(options.path) ? options.path.join("' | '") : options.path}' but got '${context.path}'`,
110+
)
111+
}
112+
113+
throw new Error(
114+
`The '${hookLabel}' hook has invalid context (${details.join(', ')}).`,
115+
)
73116
}
74117
}

0 commit comments

Comments
 (0)