diff --git a/CHANGELOG.md b/CHANGELOG.md index 9709a13..236638f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed + +- Preserve variants with broad scalar or overlapping union-valued discriminant fields when matching literals; reachable effects no longer narrow to `never`. Applies to both predicate factories, `useEffectWhenMatch`, and `MatchedDeps`, while empty selections still narrow to `never`. + ### Added +- Added `matchPredicateFor()` to bind a source union once and infer discriminant keys and scalar, multi-value or empty selections without repeating selected types. Existing `matchPredicate` calls remain supported. - Added the standalone `matchPredicate` factory for reusable discriminant guards, including composition with `createEffectWhen`. Standalone calls supply explicit `` types to preserve narrowing. ### Changed +- Expanded packed ESM/CommonJS consumer checks to verify matching exports, exact narrowing, predicate composition, empty selections and invalid selections against both emitted declaration forms. - Stabilized `useEffectWhenMatch` beyond its v1.3.0 prototype: it now accepts a single discriminant value or a readonly array and narrows the effect's dependency to the union of matched variants. Empty arrays never match and narrow to `never`; existing single-value calls and `once`/`onSkip` semantics remain unchanged. - Documented single-value and multi-value matching and reusable `matchPredicate` hooks in the README. diff --git a/README.md b/README.md index 2062d16..dc7252d 100644 --- a/README.md +++ b/README.md @@ -493,6 +493,53 @@ function QueryObserver({ query }: { query: Query }) { An empty value array never matches. Use `V = never` for a standalone empty-array predicate: `matchPredicate<"status", Query, never>("status", [])`. +### `matchPredicateFor()` + +Binds the complete source union once and returns a matching factory. Its key and selected value types are inferred from each call, so reusable hooks do not repeat selected variants in both type arguments and values. Existing `matchPredicate` calls remain supported. + +The source union still needs an explicit type: a key and selection cannot describe the fields of unselected variants. Binding it in a separate step lets TypeScript infer the later key and selection; defaulting the selected type in the existing factory would instead widen it to all source variants. + +```tsx +import { createEffectWhen, matchPredicateFor, useEffectWhen } from "@okyrychenko-dev/react-effect-when"; + +type QueryPending = { status: "pending" }; +type QuerySuccess = { status: "success"; data: string }; +type QueryError = { status: "error"; error: Error }; +type Query = QueryPending | QuerySuccess | QueryError; + +const matchQuery = matchPredicateFor(); +const matchesQuerySuccess = matchQuery("status", "success"); +const useQuerySucceeded = createEffectWhen(matchesQuerySuccess); +const useQuerySettled = createEffectWhen(matchQuery("status", ["success", "error"])); +// Empty selections infer never without a selected-value type argument. +const useNoQuery = createEffectWhen(matchQuery("status", [])); + +function QueryObserver({ query }: { query: Query }) { + useQuerySucceeded(([result]) => console.log(result.data), [query]); + useQuerySettled( + ([result]) => { + if (result.status === "success") { + console.log(result.data); + } else { + console.error(result.error); + } + }, + [query], + { once: false } + ); + useEffectWhen( + ([result]) => console.log(result.data), + [query], + matchesQuerySuccess + ); + useNoQuery(() => {}, [query]); // Never runs. +} +``` + +For base-hook composition, create a named predicate before passing it to `useEffectWhen`, as above. This lets TypeScript infer the selection before checking the effect callback. + +Only required fields shared by every source variant with string, number or symbol values can be selected as keys. Matching retains each variant whose field overlaps a selected value. Broad fields such as `status: string` or `code: number` remain in the matched type when a literal can match; the retained variant's field type is preserved. Invalid scalar or array selections are compile errors. Readonly arrays are supported; empty selections never match. Scalar equality, array membership and `once`/cleanup/`onSkip` behavior match the existing factory. `onSkip` receives the complete source union. + ### `createEffectWhen(predicate)` Creates a reusable hook with a baked-in predicate. diff --git a/scripts/check-packed-package.mjs b/scripts/check-packed-package.mjs index 96a43b8..129322d 100644 --- a/scripts/check-packed-package.mjs +++ b/scripts/check-packed-package.mjs @@ -166,11 +166,11 @@ try { ); writeFileSync( join(consumerRoot, "esm.mjs"), - `import { useEffectWhen } from "${PACKAGE_NAME}";\nif (typeof useEffectWhen !== "function") throw new Error("ESM export unavailable");\n` + `import { useEffectWhen, useEffectWhenMatch, matchPredicate, matchPredicateFor } from "${PACKAGE_NAME}";\nfor (const exported of [useEffectWhen, useEffectWhenMatch, matchPredicate, matchPredicateFor]) {\n if (typeof exported !== "function") throw new Error("ESM matching export unavailable");\n}\n` ); writeFileSync( join(consumerRoot, "cjs.cjs"), - `const { useEffectWhen } = require("${PACKAGE_NAME}");\nif (typeof useEffectWhen !== "function") throw new Error("CommonJS export unavailable");\n` + `const { useEffectWhen, useEffectWhenMatch, matchPredicate, matchPredicateFor } = require("${PACKAGE_NAME}");\nfor (const exported of [useEffectWhen, useEffectWhenMatch, matchPredicate, matchPredicateFor]) {\n if (typeof exported !== "function") throw new Error("CommonJS matching export unavailable");\n}\n` ); const typeConsumer = readFileSync(join(repositoryPath, "scripts/package-consumer.typecheck.ts")); diff --git a/scripts/package-consumer.typecheck.ts b/scripts/package-consumer.typecheck.ts index 9ee3a0f..e728eec 100644 --- a/scripts/package-consumer.typecheck.ts +++ b/scripts/package-consumer.typecheck.ts @@ -1,5 +1,7 @@ import { createEffectWhen, + matchPredicate, + matchPredicateFor, useEffectWhen, useEffectWhenMatch, } from "@okyrychenko-dev/react-effect-when"; @@ -18,50 +20,277 @@ interface SuccessfulQuery { status: "success"; } -type Query = PendingQuery | SuccessfulQuery; +interface FailedQuery { + error: Error; + status: "error"; +} + +type Query = PendingQuery | SuccessfulQuery | FailedQuery; +type SettledQuery = SuccessfulQuery | FailedQuery; + +// Check both directions, including accidental `any` or `never` inference. +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +function assertType(_value: T): void { + void _value; +} declare const query: Query; declare const nullableId: string | null; +const settledValues: readonly ["success", "error"] = ["success", "error"]; +const noValues: readonly [] = []; const isReady: GuardPredicate = ( - deps -): deps is readonly [string] => deps[0] !== null; + _deps +): _deps is readonly [string] => _deps[0] !== null; const isSuccessful: GuardPredicate = ( - deps -): deps is readonly [SuccessfulQuery] => deps[0].status === "success"; + _deps +): _deps is readonly [SuccessfulQuery] => _deps[0].status === "success"; const useEffectWhenSuccessful = createEffectWhen(isSuccessful); +const matchesSuccess = matchPredicate<"status", Query, "success">("status", "success"); +const matchesSettled = matchPredicate<"status", Query, "success" | "error">( + "status", + settledValues +); +const matchesNothing = matchPredicate<"status", Query, never>("status", noValues); +const useEffectWhenSettled = createEffectWhen(matchesSettled); +const useEffectWhenNothing = createEffectWhen(matchesNothing); +const matchQuery = matchPredicateFor(); +const useBoundSuccess = createEffectWhen(matchQuery("status", "success")); +const matchesBoundSettled = matchQuery("status", ["success", "error"]); +const useBoundSettled = createEffectWhen(matchesBoundSettled); +const useBoundReadonlySettled = createEffectWhen(matchQuery("status", settledValues)); +const useBoundNothing = createEffectWhen(matchQuery("status", [])); + +function onSkip(_deps: readonly [Query]): void { + assertType>(true); +} function usePackedPackageTypes(): void { useEffectWhen( - ([id]) => { - id satisfies string; + ([_id]) => { + assertType>(true); }, - [nullableId] as const, + [nullableId], isReady ); useEffectWhenMatch( - ([result]) => { - result.data.id satisfies string; + ([_result]) => { + assertType>(true); }, [query], "status", - "success" + "success", + { onSkip: (_deps) => assertType>(true) } + ); + + useEffectWhenMatch( + ([_result]) => assertType>(true), + [query], + "status", + settledValues + ); + useEffectWhenMatch( + ([_result]) => assertType>(true), + [query], + "status", + noValues ); useEffectWhenSuccessful( - ([result]) => { - result.data.id satisfies string; - }, + ([_result]) => assertType>(true), [query] ); + useEffectWhen( + ([_result]) => assertType>(true), + [query], + matchesSuccess, + { onSkip } + ); + useEffectWhen( + ([_result]) => assertType>(true), + [query], + matchesSettled, + { onSkip: (_deps) => assertType>(true) } + ); + useEffectWhenSettled( + ([_result]) => assertType>(true), + [query], + { onSkip: (_deps) => assertType>(true) } + ); + useEffectWhenNothing(([_result]) => assertType>(true), [query]); + + useBoundSuccess( + ([_result]) => assertType>(true), + [query], + { + onSkip: (_deps) => assertType>(true), + } + ); + useBoundSettled(([_result]) => assertType>(true), [query]); + useBoundReadonlySettled( + ([_result]) => assertType>(true), + [query] + ); + useBoundNothing(([_result]) => assertType>(true), [query]); + useEffectWhen( + ([_result]) => assertType>(true), + [query], + matchesBoundSettled, + { onSkip: (_deps) => assertType>(true) } + ); + // @ts-expect-error The bound source union rejects an unknown scalar selection. + matchQuery("status", "missing"); + // @ts-expect-error The bound source union rejects an unknown array selection. + matchQuery("status", ["success", "missing"]); + // @ts-expect-error Only fields common to every source variant can discriminate. + matchQuery("data", "anything"); + + // @ts-expect-error An unknown scalar discriminant must be rejected. + useEffectWhenMatch(() => undefined, [query], "status", "missing"); + // @ts-expect-error An unknown array discriminant must be rejected. + useEffectWhenMatch(() => undefined, [query], "status", ["success", "missing"]); + // @ts-expect-error Explicit factory selection must belong to the source union. + matchPredicate<"status", Query, "missing">("status", "missing"); + // @ts-expect-error Array selection must belong to the explicit selected union. + matchPredicate<"status", Query, "success">("status", ["success", "missing"]); } -const matchedEffect: UseEffectWhenEffect> = ([result]) => { - result.data.id satisfies string; +const matchedEffect: UseEffectWhenEffect> = ([_result]) => { + assertType>(true); }; +assertType< + Equal, readonly [SettledQuery]> +>(true); +assertType, readonly [never]>>(true); void matchedEffect; void usePackedPackageTypes; + +interface ObjectField { + metadata: { id: string }; +} +// @ts-expect-error Object-valued fields cannot be discriminant keys. +matchPredicateFor()("metadata", []); +interface OptionalField { + status?: "success"; +} +// @ts-expect-error Optional fields are not required discriminants. +matchPredicateFor()("status", "success"); + +interface ObjectStatus { + status: { id: string }; +} +// @ts-expect-error Every variant must have a PropertyKey-valued field. +matchPredicateFor()("status", "success"); +// @ts-expect-error Every variant must require the discriminant field. +matchPredicateFor()("status", "success"); + +interface BroadStringResult { + status: string; + data: string; +} +interface ClosedResult { + status: "closed"; + reason: string; +} +interface BroadNumberResult { + code: number; + data: string; +} +interface NotFoundResult { + code: 404; + error: Error; +} +interface CombinedStates { + status: "success" | "error"; + data: string; +} +interface BroadSymbolResult { + status: symbol; + data: string; +} + +declare const broadString: BroadStringResult | ClosedResult; +declare const broadNumber: BroadNumberResult | NotFoundResult; +declare const combinedStates: CombinedStates; +declare const broadSymbol: BroadSymbolResult; +declare const selectedSymbol: unique symbol; +const matchBroadString = matchPredicateFor(); +const matchBroadNumber = matchPredicateFor(); +const matchesBroadString = matchBroadString("status", "success"); +const matchesBroadNumber = matchBroadNumber("code", 200); +const matchesBroadSymbol = matchPredicateFor()("status", selectedSymbol); +const useBroadSuccess = createEffectWhen(matchesBroadString); +const useBroadNumbers = createEffectWhen(matchBroadNumber("code", [200, 201])); +const useBroadClosed = createEffectWhen(matchBroadString("status", ["success", "closed"])); +const useBroadNothing = createEffectWhen(matchBroadString("status", [])); +const matchesCombinedState = matchPredicateFor()("status", "success"); +const matchesExplicitBroadString = matchPredicate< + "status", + BroadStringResult | ClosedResult, + "success" +>("status", "success"); + +function useBroadMatchingTypes(): void { + useEffectWhen( + ([_result]) => assertType>(true), + [broadString], + matchesBroadString + ); + useBroadSuccess( + ([_result]) => assertType>(true), + [broadString], + { + onSkip: (_deps) => + assertType>(true), + } + ); + useEffectWhenMatch( + ([_result]) => assertType>(true), + [broadString], + "status", + "success" + ); + useEffectWhen( + ([_result]) => assertType>(true), + [broadString], + matchesExplicitBroadString + ); + useEffectWhen( + ([_result]) => assertType>(true), + [broadNumber], + matchesBroadNumber + ); + useBroadNumbers( + ([_result]) => assertType>(true), + [broadNumber] + ); + useBroadClosed( + ([_result]) => assertType>(true), + [broadString] + ); + useBroadNothing(([_result]) => assertType>(true), [broadString]); + useEffectWhen( + ([_result]) => assertType>(true), + [combinedStates], + matchesCombinedState + ); + useEffectWhen( + ([_result]) => assertType>(true), + [broadSymbol], + matchesBroadSymbol + ); +} +assertType< + Equal, readonly [BroadStringResult]> +>(true); +assertType, readonly [CombinedStates]>>( + true +); +assertType, readonly [never]>>(true); +void useBroadMatchingTypes; diff --git a/src/createEffectWhen/__tests__/createEffectWhen.test.ts b/src/createEffectWhen/__tests__/createEffectWhen.test.ts index 8bbcf7f..ad11c92 100644 --- a/src/createEffectWhen/__tests__/createEffectWhen.test.ts +++ b/src/createEffectWhen/__tests__/createEffectWhen.test.ts @@ -1,7 +1,7 @@ import { renderHook } from "@testing-library/react"; import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { predicates } from "../../useEffectWhen"; -import { matchPredicate } from "../../useEffectWhenMatch"; +import { matchPredicate, matchPredicateFor } from "../../useEffectWhenMatch"; import { createEffectWhen } from "../createEffectWhen"; import { tuple } from "./createEffectWhen.utils"; import type { ReadyDeps } from "../../useEffectWhen"; @@ -11,10 +11,18 @@ interface TestUser { id: string; } -type QueryState = - | { status: "idle" } - | { status: "success"; data: string } - | { status: "error"; error: Error }; +interface QueryIdle { + status: "idle"; +} +interface QuerySuccess { + status: "success"; + data: string; +} +interface QueryError { + status: "error"; + error: Error; +} +type QueryState = QueryIdle | QuerySuccess | QueryError; const truthy = (deps: DependencyList): boolean => predicates.truthy(deps); const readyPair = ( @@ -131,6 +139,85 @@ describe("createEffectWhen", () => { expect(effect).toHaveBeenCalledWith(["hello", 42]); }); + it("should infer a scalar selection after binding the source union", () => { + const matchQuery = matchPredicateFor(); + const useSuccessfulQuery = createEffectWhen(matchQuery("status", "success")); + const effect = vi.fn(); + const onSkip = vi.fn(); + const { rerender } = renderHook( + ({ query }: { query: QueryState }) => + useSuccessfulQuery( + ([result]) => { + expectTypeOf(result).toEqualTypeOf(); + effect(result.data); + }, + [query], + { + onSkip: (deps) => { + expectTypeOf(deps).toEqualTypeOf(); + onSkip(deps); + }, + } + ), + { initialProps: { query: { status: "idle" } } } + ); + + expect(onSkip).toHaveBeenCalledWith([{ status: "idle" }]); + expect(effect).not.toHaveBeenCalled(); + + rerender({ query: { status: "success", data: "result" } }); + + expect(effect).toHaveBeenCalledWith("result"); + }); + + it("should reuse a bound union for readonly multi-value selections and cleanup", () => { + const matchQuery = matchPredicateFor(); + const selected: readonly ["success", "error"] = ["success", "error"]; + const useSettledQuery = createEffectWhen(matchQuery("status", selected)); + const effect = vi.fn(); + const cleanup = vi.fn(); + const { rerender, unmount } = renderHook( + ({ query }: { query: QueryState }) => + useSettledQuery( + ([result]) => { + expectTypeOf(result).toEqualTypeOf(); + effect(result.status); + return cleanup; + }, + [query], + { once: false } + ), + { initialProps: { query: { status: "success", data: "result" } } } + ); + rerender({ query: { status: "error", error: new Error("failed") } }); + expect(effect.mock.calls).toEqual([["success"], ["error"]]); + expect(cleanup).toHaveBeenCalledTimes(1); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(2); + }); + + it("should infer never and skip every state for a bound empty selection", () => { + const useNothing = createEffectWhen(matchPredicateFor()("status", [])); + const effect = vi.fn(); + const onSkip = vi.fn(); + const { rerender } = renderHook( + ({ query }: { query: QueryState }) => + useNothing( + ([result]) => { + expectTypeOf(result).toEqualTypeOf(); + effect(result); + }, + [query], + { onSkip } + ), + { initialProps: { query: { status: "idle" } } } + ); + rerender({ query: { status: "success", data: "result" } }); + rerender({ query: { status: "error", error: new Error("failed") } }); + expect(effect).not.toHaveBeenCalled(); + expect(onSkip).toHaveBeenCalledTimes(3); + }); + it("should compose matchPredicate into a reusable narrowed hook", () => { const useEffectWhenQuerySettled = createEffectWhen( matchPredicate<"status", QueryState, "success" | "error">("status", ["success", "error"]) diff --git a/src/index.ts b/src/index.ts index 148d983..4f72d73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ export { useEffectWhen, predicates } from "./useEffectWhen"; export { useEffectWhenReady } from "./useEffectWhenReady"; export { useEffectWhenTruthy } from "./useEffectWhenTruthy"; export { useEffectWhenChanged } from "./useEffectWhenChanged"; -export { matchPredicate, useEffectWhenMatch } from "./useEffectWhenMatch"; +export { matchPredicate, matchPredicateFor, useEffectWhenMatch } from "./useEffectWhenMatch"; export { createEffectWhen } from "./createEffectWhen"; export type { Falsy, diff --git a/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts b/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts index 9a1a5b4..00f28bf 100644 --- a/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts +++ b/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts @@ -1,7 +1,9 @@ import { renderHook } from "@testing-library/react"; import { type PropsWithChildren, StrictMode, createElement } from "react"; import { describe, expect, expectTypeOf, it, vi } from "vitest"; -import { useEffectWhenMatch } from "../useEffectWhenMatch"; +import { matchPredicateFor, useEffectWhenMatch } from ".."; +import { createEffectWhen } from "../../createEffectWhen"; +import { useEffectWhen } from "../../useEffectWhen"; interface QueryData { id: string; @@ -92,6 +94,71 @@ describe("useEffectWhenMatch", () => { expect(effect).toHaveBeenCalledWith([result]); }); + it("should preserve a broad string variant when a bound literal matches", () => { + interface BroadResult { + status: string; + data: string; + } + const matchesSuccess = matchPredicateFor()("status", "success"); + const result: BroadResult = { status: "success", data: "result" }; + const effect = vi.fn(); + renderHook(() => + useEffectWhen( + ([matched]) => { + expectTypeOf(matched).toEqualTypeOf(); + effect(matched.data); + }, + [result], + matchesSuccess + ) + ); + expect(effect).toHaveBeenCalledWith("result"); + }); + + it("should preserve a broad numeric variant and exclude a disjoint literal", () => { + interface NumericSuccess { + code: number; + data: string; + } + interface NumericError { + code: 404; + error: Error; + } + type NumericResult = NumericSuccess | NumericError; + const useNumericSuccess = createEffectWhen(matchPredicateFor()("code", 200)); + const result: NumericResult = { code: 200, data: "result" }; + const effect = vi.fn(); + renderHook(() => + useNumericSuccess( + ([matched]) => { + expectTypeOf(matched).toEqualTypeOf(); + effect(matched.data); + }, + [result] + ) + ); + expect(effect).toHaveBeenCalledWith("result"); + }); + + it("should preserve scalar equality and array membership in bound matching", () => { + interface NumericResult { + code: number; + } + + const matchNumber = matchPredicateFor(); + const scalarEffect = vi.fn(); + const arrayEffect = vi.fn(); + const result: NumericResult = { code: Number.NaN }; + + renderHook(() => { + useEffectWhen(scalarEffect, [result], matchNumber("code", Number.NaN)); + useEffectWhen(arrayEffect, [result], matchNumber("code", [Number.NaN])); + }); + + expect(scalarEffect).not.toHaveBeenCalled(); + expect(arrayEffect).toHaveBeenCalledWith([result]); + }); + it("should not run when the field does not match", () => { const track = vi.fn<(id: string) => void>(); const query = queryResult({ status: "pending" }); diff --git a/src/useEffectWhenMatch/index.ts b/src/useEffectWhenMatch/index.ts index 15b4e3e..b84863f 100644 --- a/src/useEffectWhenMatch/index.ts +++ b/src/useEffectWhenMatch/index.ts @@ -1,3 +1,3 @@ export { useEffectWhenMatch } from "./useEffectWhenMatch"; -export { matchPredicate } from "./useEffectWhenMatch.utils"; +export { matchPredicate, matchPredicateFor } from "./useEffectWhenMatch.utils"; export type { Discriminant, MatchedDeps } from "./useEffectWhenMatch.types"; diff --git a/src/useEffectWhenMatch/useEffectWhenMatch.types.ts b/src/useEffectWhenMatch/useEffectWhenMatch.types.ts index 5df3cbc..2eb4cb3 100644 --- a/src/useEffectWhenMatch/useEffectWhenMatch.types.ts +++ b/src/useEffectWhenMatch/useEffectWhenMatch.types.ts @@ -1,12 +1,31 @@ -/** An object discriminated by a literal-valued field at key `K`. */ +import type { GuardPredicate } from "../useEffectWhen"; + +/** An object with a string, number or symbol field at key `K`. */ export type Discriminant = Record; -/** - * Narrows a single dependency `Q` (discriminated by `K`) down to the variant - * whose `K` field equals `V`. - */ +/** Retains each source variant whose field overlaps a selected value. */ +export type MatchedVariant = + Q extends Record ? ([Q[K] & V] extends [never] ? never : Q) : never; + +/** Narrows a single dependency to variants whose field can match the selection. */ export type MatchedDeps< K extends PropertyKey, Q extends Discriminant, V extends Q[K] | ReadonlyArray, -> = readonly [Extract ? E : V>>]; +> = readonly [MatchedVariant ? E : V>]; + +/** Common, required fields whose values can discriminate the source union. */ +export type DiscriminantKey = K extends keyof Q + ? [Q] extends [Discriminant] + ? K + : never + : never; + +/** A matching factory with its complete source union bound once. */ +export type BoundMatchPredicate = < + K extends DiscriminantKey, + V extends Q[K] & PropertyKey, +>( + key: K, + value: V | ReadonlyArray +) => GuardPredicate]>; diff --git a/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts b/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts index 37289a5..a679d41 100644 --- a/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts +++ b/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts @@ -1,4 +1,10 @@ -import type { Discriminant, MatchedDeps } from "./useEffectWhenMatch.types"; +import type { + BoundMatchPredicate, + Discriminant, + DiscriminantKey, + MatchedDeps, + MatchedVariant, +} from "./useEffectWhenMatch.types"; import type { GuardPredicate } from "../useEffectWhen"; function isReadonlyArray(value: T | ReadonlyArray): value is ReadonlyArray { @@ -9,15 +15,34 @@ function includesValue(values: ReadonlyArray, candidate: T): boolean { return values.includes(candidate); } +function matchesValue(value: T | ReadonlyArray, candidate: T): boolean { + if (isReadonlyArray(value)) { + return includesValue(value, candidate); + } + + return candidate === value; +} + export function matchPredicate, V extends Q[K]>( key: K, value: V | ReadonlyArray ): GuardPredicate> { return function matchesDiscriminant(deps): deps is MatchedDeps { - if (isReadonlyArray(value)) { - return includesValue(value, deps[0][key]); - } + return matchesValue(value, deps[0][key]); + }; +} - return deps[0][key] === value; +/** + * Binds the complete source union once, then infers each key and selection. + * Existing explicit-generic matchPredicate calls remain supported. + */ +export function matchPredicateFor(): BoundMatchPredicate { + return function matchSource, V extends Q[K] & PropertyKey>( + key: K, + value: V | ReadonlyArray + ): GuardPredicate]> { + return function matchesDiscriminant(deps): deps is readonly [MatchedVariant] { + return matchesValue(value, deps[0][key]); + }; }; }