From 011bbb6fab0bc0abea2f589f998aa2d543a65e71 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Thu, 1 Oct 2026 12:23:24 +0300 Subject: [PATCH 1/2] feat: infer reusable discriminant match selections --- CHANGELOG.md | 2 + README.md | 47 ++++++ scripts/check-packed-package.mjs | 4 +- scripts/package-consumer.typecheck.ts | 156 ++++++++++++++++-- .../__tests__/createEffectWhen.test.ts | 97 ++++++++++- src/index.ts | 2 +- .../__tests__/useEffectWhenMatch.test.ts | 22 ++- src/useEffectWhenMatch/index.ts | 2 +- .../useEffectWhenMatch.types.ts | 18 ++ .../useEffectWhenMatch.utils.ts | 34 +++- 10 files changed, 353 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9709a13..2f02b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,12 @@ All notable changes to this project will be documented in this file. ### 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..ae0c233 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. 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..f00fea6 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,172 @@ 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"); 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..8aef9ba 100644 --- a/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts +++ b/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts @@ -1,7 +1,8 @@ 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 { useEffectWhen } from "../../useEffectWhen"; interface QueryData { id: string; @@ -92,6 +93,25 @@ describe("useEffectWhenMatch", () => { 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..5155586 100644 --- a/src/useEffectWhenMatch/useEffectWhenMatch.types.ts +++ b/src/useEffectWhenMatch/useEffectWhenMatch.types.ts @@ -1,3 +1,5 @@ +import type { GuardPredicate } from "../useEffectWhen"; + /** An object discriminated by a literal-valued field at key `K`. */ export type Discriminant = Record; @@ -10,3 +12,19 @@ export type MatchedDeps< Q extends Discriminant, V extends Q[K] | ReadonlyArray, > = readonly [Extract ? 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..c0d7284 100644 --- a/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts +++ b/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts @@ -1,4 +1,9 @@ -import type { Discriminant, MatchedDeps } from "./useEffectWhenMatch.types"; +import type { + BoundMatchPredicate, + Discriminant, + DiscriminantKey, + MatchedDeps, +} from "./useEffectWhenMatch.types"; import type { GuardPredicate } from "../useEffectWhen"; function isReadonlyArray(value: T | ReadonlyArray): value is ReadonlyArray { @@ -9,15 +14,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 [Extract>] { + return matchesValue(value, deps[0][key]); + }; }; } From 94a14eceb228d531b49c416f3133d5f2cee59993 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Thu, 1 Oct 2026 12:34:28 +0300 Subject: [PATCH 2/2] fix: preserve overlapping discriminant variants --- CHANGELOG.md | 4 + README.md | 2 +- scripts/package-consumer.typecheck.ts | 105 ++++++++++++++++++ .../__tests__/useEffectWhenMatch.test.ts | 47 ++++++++ .../useEffectWhenMatch.types.ts | 15 +-- .../useEffectWhenMatch.utils.ts | 5 +- 6 files changed, 168 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f02b7d..236638f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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. diff --git a/README.md b/README.md index ae0c233..dc7252d 100644 --- a/README.md +++ b/README.md @@ -538,7 +538,7 @@ function QueryObserver({ query }: { query: Query }) { 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. 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. +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)` diff --git a/scripts/package-consumer.typecheck.ts b/scripts/package-consumer.typecheck.ts index f00fea6..e728eec 100644 --- a/scripts/package-consumer.typecheck.ts +++ b/scripts/package-consumer.typecheck.ts @@ -189,3 +189,108 @@ interface ObjectStatus { 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/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts b/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts index 8aef9ba..00f28bf 100644 --- a/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts +++ b/src/useEffectWhenMatch/__tests__/useEffectWhenMatch.test.ts @@ -2,6 +2,7 @@ import { renderHook } from "@testing-library/react"; import { type PropsWithChildren, StrictMode, createElement } from "react"; import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { matchPredicateFor, useEffectWhenMatch } from ".."; +import { createEffectWhen } from "../../createEffectWhen"; import { useEffectWhen } from "../../useEffectWhen"; interface QueryData { @@ -93,6 +94,52 @@ 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; diff --git a/src/useEffectWhenMatch/useEffectWhenMatch.types.ts b/src/useEffectWhenMatch/useEffectWhenMatch.types.ts index 5155586..2eb4cb3 100644 --- a/src/useEffectWhenMatch/useEffectWhenMatch.types.ts +++ b/src/useEffectWhenMatch/useEffectWhenMatch.types.ts @@ -1,17 +1,18 @@ import type { GuardPredicate } from "../useEffectWhen"; -/** An object discriminated by a literal-valued field at key `K`. */ +/** 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 @@ -27,4 +28,4 @@ export type BoundMatchPredicate = < >( key: K, value: V | ReadonlyArray -) => GuardPredicate>]>; +) => GuardPredicate]>; diff --git a/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts b/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts index c0d7284..a679d41 100644 --- a/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts +++ b/src/useEffectWhenMatch/useEffectWhenMatch.utils.ts @@ -3,6 +3,7 @@ import type { Discriminant, DiscriminantKey, MatchedDeps, + MatchedVariant, } from "./useEffectWhenMatch.types"; import type { GuardPredicate } from "../useEffectWhen"; @@ -39,8 +40,8 @@ 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 [Extract>] { + ): GuardPredicate]> { + return function matchesDiscriminant(deps): deps is readonly [MatchedVariant] { return matchesValue(value, deps[0][key]); }; };