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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Q>()` to bind a source union once and infer discriminant keys and scalar, multi-value or empty selections without repeating selected types. Existing `matchPredicate<K, Q, V>` calls remain supported.
- Added the standalone `matchPredicate` factory for reusable discriminant guards, including composition with `createEffectWhen`. Standalone calls supply explicit `<K, Q, V>` 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.

Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Q>()`

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<K, Q, V>` 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<Query>();
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.
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-packed-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
261 changes: 245 additions & 16 deletions scripts/package-consumer.typecheck.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
createEffectWhen,
matchPredicate,
matchPredicateFor,
useEffectWhen,
useEffectWhenMatch,
} from "@okyrychenko-dev/react-effect-when";
Expand All @@ -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<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;

function assertType<T extends true>(_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<readonly [string | null], readonly [string]> = (
deps
): deps is readonly [string] => deps[0] !== null;
_deps
): _deps is readonly [string] => _deps[0] !== null;

const isSuccessful: GuardPredicate<readonly [Query], readonly [SuccessfulQuery]> = (
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<Query>();
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<Equal<typeof _deps, readonly [Query]>>(true);
}

function usePackedPackageTypes(): void {
useEffectWhen(
([id]) => {
id satisfies string;
([_id]) => {
assertType<Equal<typeof _id, string>>(true);
},
[nullableId] as const,
[nullableId],
isReady
);

useEffectWhenMatch(
([result]) => {
result.data.id satisfies string;
([_result]) => {
assertType<Equal<typeof _result, SuccessfulQuery>>(true);
},
[query],
"status",
"success"
"success",
{ onSkip: (_deps) => assertType<Equal<typeof _deps, readonly [Query]>>(true) }
);

useEffectWhenMatch(
([_result]) => assertType<Equal<typeof _result, SettledQuery>>(true),
[query],
"status",
settledValues
);
useEffectWhenMatch(
([_result]) => assertType<Equal<typeof _result, never>>(true),
[query],
"status",
noValues
);

useEffectWhenSuccessful(
([result]) => {
result.data.id satisfies string;
},
([_result]) => assertType<Equal<typeof _result, SuccessfulQuery>>(true),
[query]
);
useEffectWhen(
([_result]) => assertType<Equal<typeof _result, SuccessfulQuery>>(true),
[query],
matchesSuccess,
{ onSkip }
);
useEffectWhen(
([_result]) => assertType<Equal<typeof _result, SettledQuery>>(true),
[query],
matchesSettled,
{ onSkip: (_deps) => assertType<Equal<typeof _deps, readonly [Query]>>(true) }
);
useEffectWhenSettled(
([_result]) => assertType<Equal<typeof _result, SettledQuery>>(true),
[query],
{ onSkip: (_deps) => assertType<Equal<typeof _deps, readonly [Query]>>(true) }
);
useEffectWhenNothing(([_result]) => assertType<Equal<typeof _result, never>>(true), [query]);

useBoundSuccess(
([_result]) => assertType<Equal<typeof _result, SuccessfulQuery>>(true),
[query],
{
onSkip: (_deps) => assertType<Equal<typeof _deps, readonly [Query]>>(true),
}
);
useBoundSettled(([_result]) => assertType<Equal<typeof _result, SettledQuery>>(true), [query]);
useBoundReadonlySettled(
([_result]) => assertType<Equal<typeof _result, SettledQuery>>(true),
[query]
);
useBoundNothing(([_result]) => assertType<Equal<typeof _result, never>>(true), [query]);
useEffectWhen(
([_result]) => assertType<Equal<typeof _result, SettledQuery>>(true),
[query],
matchesBoundSettled,
{ onSkip: (_deps) => assertType<Equal<typeof _deps, readonly [Query]>>(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<MatchedDeps<"status", Query, "success">> = ([result]) => {
result.data.id satisfies string;
const matchedEffect: UseEffectWhenEffect<MatchedDeps<"status", Query, "success">> = ([_result]) => {
assertType<Equal<typeof _result, SuccessfulQuery>>(true);
};
assertType<
Equal<MatchedDeps<"status", Query, readonly ["success", "error"]>, readonly [SettledQuery]>
>(true);
assertType<Equal<MatchedDeps<"status", Query, readonly []>, readonly [never]>>(true);

void matchedEffect;
void usePackedPackageTypes;

interface ObjectField {
metadata: { id: string };
}
// @ts-expect-error Object-valued fields cannot be discriminant keys.
matchPredicateFor<ObjectField>()("metadata", []);
interface OptionalField {
status?: "success";
}
// @ts-expect-error Optional fields are not required discriminants.
matchPredicateFor<OptionalField>()("status", "success");

interface ObjectStatus {
status: { id: string };
}
// @ts-expect-error Every variant must have a PropertyKey-valued field.
matchPredicateFor<SuccessfulQuery | ObjectStatus>()("status", "success");
// @ts-expect-error Every variant must require the discriminant field.
matchPredicateFor<SuccessfulQuery | OptionalField>()("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<BroadStringResult | ClosedResult>();
const matchBroadNumber = matchPredicateFor<BroadNumberResult | NotFoundResult>();
const matchesBroadString = matchBroadString("status", "success");
const matchesBroadNumber = matchBroadNumber("code", 200);
const matchesBroadSymbol = matchPredicateFor<BroadSymbolResult>()("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<CombinedStates>()("status", "success");
const matchesExplicitBroadString = matchPredicate<
"status",
BroadStringResult | ClosedResult,
"success"
>("status", "success");

function useBroadMatchingTypes(): void {
useEffectWhen(
([_result]) => assertType<Equal<typeof _result, BroadStringResult>>(true),
[broadString],
matchesBroadString
);
useBroadSuccess(
([_result]) => assertType<Equal<typeof _result, BroadStringResult>>(true),
[broadString],
{
onSkip: (_deps) =>
assertType<Equal<typeof _deps, readonly [BroadStringResult | ClosedResult]>>(true),
}
);
useEffectWhenMatch(
([_result]) => assertType<Equal<typeof _result, BroadStringResult>>(true),
[broadString],
"status",
"success"
);
useEffectWhen(
([_result]) => assertType<Equal<typeof _result, BroadStringResult>>(true),
[broadString],
matchesExplicitBroadString
);
useEffectWhen(
([_result]) => assertType<Equal<typeof _result, BroadNumberResult>>(true),
[broadNumber],
matchesBroadNumber
);
useBroadNumbers(
([_result]) => assertType<Equal<typeof _result, BroadNumberResult>>(true),
[broadNumber]
);
useBroadClosed(
([_result]) => assertType<Equal<typeof _result, BroadStringResult | ClosedResult>>(true),
[broadString]
);
useBroadNothing(([_result]) => assertType<Equal<typeof _result, never>>(true), [broadString]);
useEffectWhen(
([_result]) => assertType<Equal<typeof _result, CombinedStates>>(true),
[combinedStates],
matchesCombinedState
);
useEffectWhen(
([_result]) => assertType<Equal<typeof _result, BroadSymbolResult>>(true),
[broadSymbol],
matchesBroadSymbol
);
}
assertType<
Equal<MatchedDeps<"status", BroadStringResult, "success">, readonly [BroadStringResult]>
>(true);
assertType<Equal<MatchedDeps<"status", CombinedStates, "success">, readonly [CombinedStates]>>(
true
);
assertType<Equal<MatchedDeps<"status", BroadStringResult, readonly []>, readonly [never]>>(true);
void useBroadMatchingTypes;
Loading
Loading