From 4d11e19d0f8884601aef1ad24b9775e615c33b02 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sat, 5 Sep 2026 14:11:51 +0300 Subject: [PATCH] refactor: organize public type contracts --- src/core/createShallowStore.ts | 8 +- src/core/createShallowStore.types.ts | 9 -- src/core/createStoreToolkit.ts | 9 +- src/core/index.ts | 3 +- src/hooks/createResolvedStoreHooks.ts | 26 +-- src/hooks/createResolvedStoreHooks.types.ts | 9 -- src/hooks/index.ts | 9 +- src/hooks/storeSelection/index.ts | 7 +- .../storeSelection/storeSelection.types.ts | 11 +- src/index.ts | 13 +- src/providers/createStoreProvider.tsx | 37 ++--- src/providers/index.ts | 6 +- src/type-tests/README.md | 11 ++ .../middlewareCompatibility.type-test.ts | 150 ++++++++++++++++++ .../resolvedStoreContract.type-test.ts | 30 ++++ src/types/index.ts | 10 +- src/types/store-bindings.types.ts | 16 ++ src/types/store-hooks.types.ts | 9 ++ .../store-provider.types.ts} | 4 +- .../store-toolkit.types.ts} | 7 +- src/types/{store.ts => store.types.ts} | 0 21 files changed, 272 insertions(+), 112 deletions(-) delete mode 100644 src/core/createShallowStore.types.ts delete mode 100644 src/hooks/createResolvedStoreHooks.types.ts create mode 100644 src/type-tests/README.md create mode 100644 src/type-tests/middlewareCompatibility.type-test.ts create mode 100644 src/type-tests/resolvedStoreContract.type-test.ts create mode 100644 src/types/store-bindings.types.ts create mode 100644 src/types/store-hooks.types.ts rename src/{providers/createStoreProvider.types.ts => types/store-provider.types.ts} (89%) rename src/{core/createStoreToolkit.types.ts => types/store-toolkit.types.ts} (60%) rename src/types/{store.ts => store.types.ts} (100%) diff --git a/src/core/createShallowStore.ts b/src/core/createShallowStore.ts index e91bb72..c57a23a 100644 --- a/src/core/createShallowStore.ts +++ b/src/core/createShallowStore.ts @@ -1,7 +1,11 @@ import { createStore } from "zustand"; import { createStoreSelectionBindings } from "../hooks"; -import type { MutatorsStateCreator, StoreApiWithMutators, StoreMutatorTuple } from "../types"; -import type { ShallowStoreBindings } from "./createShallowStore.types"; +import type { + MutatorsStateCreator, + ShallowStoreBindings, + StoreApiWithMutators, + StoreMutatorTuple, +} from "../types"; /** * Creates a Zustand store with shallow-first selector semantics. diff --git a/src/core/createShallowStore.types.ts b/src/core/createShallowStore.types.ts deleted file mode 100644 index e5666a1..0000000 --- a/src/core/createShallowStore.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { StorePlainHook, StoreValueHook } from "../hooks"; -import type { StoreApiWithMutators, StoreMutatorTuple } from "../types"; - -/** Store bindings with shallow comparison built in. */ -export interface ShallowStoreBindings = []> { - useStore: StoreValueHook; - useStorePlain: StorePlainHook; - useStoreApi: StoreApiWithMutators; -} diff --git a/src/core/createStoreToolkit.ts b/src/core/createStoreToolkit.ts index 2b9d4a4..d48fcd9 100644 --- a/src/core/createStoreToolkit.ts +++ b/src/core/createStoreToolkit.ts @@ -1,9 +1,12 @@ import { createResolvedStoreHooks } from "../hooks"; import { createStoreProvider } from "../providers"; import { createShallowStore } from "./createShallowStore"; -import type { StoreProviderResult } from "../providers"; -import type { MutatorsStateCreator, StoreMutatorTuple } from "../types"; -import type { StoreToolkit } from "./createStoreToolkit.types"; +import type { + MutatorsStateCreator, + StoreMutatorTuple, + StoreProviderResult, + StoreToolkit, +} from "../types"; /** * Creates a complete Zustand store toolkit with global store, provider, and resolution hooks diff --git a/src/core/index.ts b/src/core/index.ts index 7fe782e..6eeec53 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,4 +1,3 @@ export { createShallowStore } from "./createShallowStore"; export { createStoreToolkit } from "./createStoreToolkit"; -export type { ShallowStoreBindings } from "./createShallowStore.types"; -export type { StoreToolkit } from "./createStoreToolkit.types"; +export type { ShallowStoreBindings, StoreToolkit } from "../types"; diff --git a/src/hooks/createResolvedStoreHooks.ts b/src/hooks/createResolvedStoreHooks.ts index 65bc86f..5ace941 100644 --- a/src/hooks/createResolvedStoreHooks.ts +++ b/src/hooks/createResolvedStoreHooks.ts @@ -1,6 +1,5 @@ -import { useStoreSelection, useStoreSelectionPlain } from "./storeSelection"; -import type { StoreApiWithMutators, StoreMutatorTuple } from "../types"; -import type { ResolvedStoreBindings } from "./createResolvedStoreHooks.types"; +import { createStoreSelectionBindings } from "./storeSelection"; +import type { ResolvedStoreBindings, StoreApiWithMutators, StoreMutatorTuple } from "../types"; /** * Creates hooks that resolve between context store and global store @@ -23,25 +22,8 @@ export function createResolvedStoreHooks( - selector: (state: TState) => T, - equalityFn?: (a: T, b: T) => boolean - ): T; - function useResolvedValue( - selector?: (state: TState) => T, - equalityFn?: (a: T | TState, b: T | TState) => boolean - ): T | TState { - const store = useResolvedStoreApi(); - return useStoreSelection(store, selector, equalityFn); - } - - function useResolvedStorePlain(): TState; - function useResolvedStorePlain(selector: (state: TState) => T): T; - function useResolvedStorePlain(selector?: (state: TState) => T): T | TState { - const store = useResolvedStoreApi(); - return useStoreSelectionPlain(store, selector); - } + const { useStoreValue: useResolvedValue, useStorePlain: useResolvedStorePlain } = + createStoreSelectionBindings(useResolvedStoreApi); return { useResolvedStoreApi, diff --git a/src/hooks/createResolvedStoreHooks.types.ts b/src/hooks/createResolvedStoreHooks.types.ts deleted file mode 100644 index 53860eb..0000000 --- a/src/hooks/createResolvedStoreHooks.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { StoreApiWithMutators, StoreMutatorTuple } from "../types"; -import type { StorePlainHook, StoreValueHook } from "./storeSelection"; - -/** Bindings that resolve to a provider store when present, otherwise the global store. */ -export interface ResolvedStoreBindings = []> { - useResolvedStoreApi: () => StoreApiWithMutators; - useResolvedValue: StoreValueHook; - useResolvedStorePlain: StorePlainHook; -} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 2d25ffa..695013a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,5 +1,5 @@ export { createResolvedStoreHooks } from "./createResolvedStoreHooks"; -export type { ResolvedStoreBindings } from "./createResolvedStoreHooks.types"; +export type { ResolvedStoreBindings, StorePlainHook, StoreValueHook } from "../types"; export { useSelectorWithEquality } from "./useSelectorWithEquality"; export type { UseSelectorWithEqualityEqualityFn, @@ -9,9 +9,4 @@ export type { } from "./useSelectorWithEquality"; export { createStoreSelectionBindings } from "./storeSelection"; export { useStoreSelection, useStoreSelectionPlain } from "./storeSelection"; -export type { - StorePlainHook, - StoreResolver, - StoreSelectionBindings, - StoreValueHook, -} from "./storeSelection"; +export type { StoreResolver, StoreSelectionBindings } from "./storeSelection"; diff --git a/src/hooks/storeSelection/index.ts b/src/hooks/storeSelection/index.ts index 69cc4aa..3106814 100644 --- a/src/hooks/storeSelection/index.ts +++ b/src/hooks/storeSelection/index.ts @@ -1,8 +1,3 @@ export { createStoreSelectionBindings } from "./createStoreSelectionBindings"; export { useStoreSelection, useStoreSelectionPlain } from "./useStoreSelection"; -export type { - StorePlainHook, - StoreResolver, - StoreSelectionBindings, - StoreValueHook, -} from "./storeSelection.types"; +export type { StoreResolver, StoreSelectionBindings } from "./storeSelection.types"; diff --git a/src/hooks/storeSelection/storeSelection.types.ts b/src/hooks/storeSelection/storeSelection.types.ts index 74a2d11..7e7a4c1 100644 --- a/src/hooks/storeSelection/storeSelection.types.ts +++ b/src/hooks/storeSelection/storeSelection.types.ts @@ -1,20 +1,11 @@ import type { StoreApi } from "zustand"; +import type { StorePlainHook, StoreValueHook } from "../../types/store-hooks.types"; export type SelectionStore = Pick< StoreApi, "getState" | "getInitialState" | "subscribe" >; -export interface StoreValueHook { - (): TState; - (selector: (state: TState) => T, equalityFn?: (left: T, right: T) => boolean): T; -} - -export interface StorePlainHook { - (): TState; - (selector: (state: TState) => T): T; -} - export type StoreResolver = () => SelectionStore; export interface StoreSelectionBindings { diff --git a/src/index.ts b/src/index.ts index 74a73c3..fa0a49e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,22 +1,27 @@ // Core utilities export { createShallowStore, createStoreToolkit } from "./core"; -export type { ShallowStoreBindings, StoreToolkit } from "./core"; // Provider utilities export { createStoreProvider } from "./providers"; -export type { StoreProviderConfig, StoreProviderProps, StoreProviderResult } from "./providers"; // Hook utilities export { createResolvedStoreHooks } from "./hooks"; -export type { ResolvedStoreBindings, StorePlainHook, StoreValueHook } from "./hooks"; // React 19 utilities export { createTransitionAction, useActionStateAdapter, useOptimisticReducer } from "./react19"; -// Shared store types +// Types export type { MutatorsStateCreator, + ResolvedStoreBindings, + ShallowStoreBindings, SimpleStateCreator, StoreApiWithMutators, + StorePlainHook, + StoreProviderConfig, + StoreProviderProps, + StoreProviderResult, StoreMutatorTuple, + StoreToolkit, + StoreValueHook, } from "./types"; diff --git a/src/providers/createStoreProvider.tsx b/src/providers/createStoreProvider.tsx index 26e11e5..aa7869a 100644 --- a/src/providers/createStoreProvider.tsx +++ b/src/providers/createStoreProvider.tsx @@ -1,8 +1,13 @@ import { type ReactNode, createContext, useContext, useEffect, useRef, useState } from "react"; import { createStore } from "zustand"; -import { useStoreSelection, useStoreSelectionPlain } from "../hooks"; -import type { MutatorsStateCreator, StoreApiWithMutators, StoreMutatorTuple } from "../types"; -import type { StoreProviderProps, StoreProviderResult } from "./createStoreProvider.types"; +import { createStoreSelectionBindings } from "../hooks"; +import type { + MutatorsStateCreator, + StoreApiWithMutators, + StoreMutatorTuple, + StoreProviderProps, + StoreProviderResult, +} from "../types"; /** * Creates a React Context provider for isolated Zustand store instances. @@ -193,33 +198,13 @@ export function createStoreProvider(selector: (state: TState) => T): T; - function useContextStoreWithSelector( - selector: (state: TState) => T, - equalityFn: (a: T, b: T) => boolean - ): T; - function useContextStoreWithSelector( - selector?: (state: TState) => T, - equalityFn?: (a: T | TState, b: T | TState) => boolean - ): T | TState { - const store = useStoreContext(); - - return useStoreSelection(store, selector, equalityFn); - } - - function useContextStorePlain(): TState; - function useContextStorePlain(selector: (state: TState) => T): T; - function useContextStorePlain(selector?: (state: TState) => T): T | TState { - const store = useStoreContext(); - - return useStoreSelectionPlain(store, selector); - } + const { useStoreValue: useContextStore, useStorePlain: useContextStorePlain } = + createStoreSelectionBindings(useStoreContext); return { Provider, useContextStoreApi: useStoreContext, - useContextStore: useContextStoreWithSelector, + useContextStore, useContextStorePlain, useIsInsideProvider, useContextStoreOptional, diff --git a/src/providers/index.ts b/src/providers/index.ts index c8ac84c..fba66b8 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,6 +1,2 @@ export { createStoreProvider } from "./createStoreProvider"; -export type { - StoreProviderConfig, - StoreProviderProps, - StoreProviderResult, -} from "./createStoreProvider.types"; +export type { StoreProviderConfig, StoreProviderProps, StoreProviderResult } from "../types"; diff --git a/src/type-tests/README.md b/src/type-tests/README.md new file mode 100644 index 0000000..8ba6d96 --- /dev/null +++ b/src/type-tests/README.md @@ -0,0 +1,11 @@ +# Compile-time type tests + +This folder contains TypeScript programs that verify the package's public type contracts. They are +compiled by `npm run typecheck` through the root `tsconfig.json`; they are not Vitest runtime tests. + +Each `*.type-test.ts` file should exercise a public consumer scenario that must either compile or +produce a deliberate `@ts-expect-error`. Keep runtime behavior tests beside their owning modules in +`__tests__` folders. + +The package build starts from the public entry point, so these files do not become runtime exports +or bundled JavaScript. diff --git a/src/type-tests/middlewareCompatibility.type-test.ts b/src/type-tests/middlewareCompatibility.type-test.ts new file mode 100644 index 0000000..b0090f0 --- /dev/null +++ b/src/type-tests/middlewareCompatibility.type-test.ts @@ -0,0 +1,150 @@ +import { createJSONStorage, devtools, persist, subscribeWithSelector } from "zustand/middleware"; +import { immer } from "zustand/middleware/immer"; +import { + type ResolvedStoreBindings, + createResolvedStoreHooks, + createShallowStore, + createStoreProvider, + createStoreToolkit, +} from "../index"; + +interface CounterState { + count: number; + increment: () => void; +} + +type Assert = T; +type HasKey = K extends keyof T ? true : false; +type IsCallable = T extends (...args: Array) => unknown ? true : false; + +const storage = createJSONStorage(() => ({ + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, +})); + +const _devtoolsStore = createShallowStore( + devtools( + (set) => ({ + count: 0, + increment: () => { + set((state) => ({ count: state.count + 1 })); + }, + }), + { name: "DevtoolsStore" } + ) +); + +type DevtoolsCleanupAssertion = Assert< + IsCallable +>; + +const _persistStore = createShallowStore( + persist( + (set) => ({ + count: 0, + increment: () => { + set((state) => ({ count: state.count + 1 })); + }, + }), + { name: "PersistStore", storage } + ) +); + +type PersistApiAssertion = Assert>; + +const _subscribeStore = createShallowStore< + CounterState, + [["zustand/subscribeWithSelector", never]] +>( + subscribeWithSelector((set) => ({ + count: 0, + increment: () => { + set((state) => ({ count: state.count + 1 })); + }, + })) +); + +const _unsubscribe = _subscribeStore.useStoreApi.subscribe( + (state) => state.count, + (selected, previous) => { + const nextCount: number = selected; + const prevCount: number = previous; + + void nextCount; + void prevCount; + }, + { fireImmediately: true } +); + +void _unsubscribe; + +const _immerStore = createShallowStore( + immer((set) => ({ + count: 0, + increment: () => { + set({ count: 1 }); + }, + })) +); + +const _immerUpdater: Parameters[0] = ( + draft: CounterState +) => { + draft.count += 1; +}; + +void _immerUpdater; + +type CombinedMutators = [["zustand/persist", CounterState], ["zustand/devtools", never]]; + +const combinedCreator = persist( + devtools( + (set) => ({ + count: 0, + increment: () => { + set((state) => ({ count: state.count + 1 })); + }, + }), + { name: "CombinedStore" } + ), + { name: "CombinedStore", storage } +); + +const _provider = createStoreProvider(combinedCreator); +const _toolkit = createStoreToolkit(combinedCreator); +const _standaloneResolved = createResolvedStoreHooks( + _toolkit.useStoreApi, + _provider.useContextStoreOptional +); +const _toolkitResolvedContract: ResolvedStoreBindings = _toolkit; +const _standaloneResolvedContract: ResolvedStoreBindings = + _standaloneResolved; + +type ProviderPersistAssertion = Assert< + HasKey, "persist"> +>; +type ProviderDevtoolsAssertion = Assert< + HasKey, "devtools"> +>; +type ToolkitPersistAssertion = Assert>; +type ToolkitDevtoolsAssertion = Assert>; +type ResolvedPersistAssertion = Assert< + HasKey, "persist"> +>; +type StandaloneResolvedDevtoolsAssertion = Assert< + HasKey, "devtools"> +>; + +void _toolkitResolvedContract; + +export type MiddlewareCompatibilityAssertions = [ + DevtoolsCleanupAssertion, + PersistApiAssertion, + ProviderPersistAssertion, + ProviderDevtoolsAssertion, + ToolkitPersistAssertion, + ToolkitDevtoolsAssertion, + ResolvedPersistAssertion, + StandaloneResolvedDevtoolsAssertion, +]; diff --git a/src/type-tests/resolvedStoreContract.type-test.ts b/src/type-tests/resolvedStoreContract.type-test.ts new file mode 100644 index 0000000..83efec5 --- /dev/null +++ b/src/type-tests/resolvedStoreContract.type-test.ts @@ -0,0 +1,30 @@ +import { createStore } from "zustand"; +import { type ResolvedStoreBindings, createResolvedStoreHooks, createStoreToolkit } from "../index"; + +interface CounterState { + count: number; +} + +const globalStore = createStore(() => ({ count: 0 })); +const resolvedBindings: ResolvedStoreBindings = createResolvedStoreHooks( + globalStore, + () => null +); + +const fullState: CounterState = resolvedBindings.useResolvedValue(); +const selectedCount: number = resolvedBindings.useResolvedValue((state) => state.count); +const equalCount: number = resolvedBindings.useResolvedValue((state) => state.count, Object.is); +const plainState: CounterState = resolvedBindings.useResolvedStorePlain(); +const plainCount: number = resolvedBindings.useResolvedStorePlain((state) => state.count); +const resolvedApi = resolvedBindings.useResolvedStoreApi(); + +const toolkit = createStoreToolkit(() => ({ count: 0 })); +const toolkitBindings: ResolvedStoreBindings = toolkit; + +void fullState; +void selectedCount; +void equalCount; +void plainState; +void plainCount; +void resolvedApi; +void toolkitBindings; diff --git a/src/types/index.ts b/src/types/index.ts index 1e5895f..6a60f5a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,14 @@ +export type { ResolvedStoreBindings, ShallowStoreBindings } from "./store-bindings.types"; +export type { StorePlainHook, StoreValueHook } from "./store-hooks.types"; export type { MutatorsStateCreator, SimpleStateCreator, StoreApiWithMutators, StoreMutatorTuple, -} from "./store"; +} from "./store.types"; +export type { + StoreProviderConfig, + StoreProviderProps, + StoreProviderResult, +} from "./store-provider.types"; +export type { StoreToolkit } from "./store-toolkit.types"; diff --git a/src/types/store-bindings.types.ts b/src/types/store-bindings.types.ts new file mode 100644 index 0000000..10339d4 --- /dev/null +++ b/src/types/store-bindings.types.ts @@ -0,0 +1,16 @@ +import type { StorePlainHook, StoreValueHook } from "./store-hooks.types"; +import type { StoreApiWithMutators, StoreMutatorTuple } from "./store.types"; + +/** Bindings that resolve to a provider store when present, otherwise the global store. */ +export interface ResolvedStoreBindings = []> { + useResolvedStoreApi: () => StoreApiWithMutators; + useResolvedValue: StoreValueHook; + useResolvedStorePlain: StorePlainHook; +} + +/** Store bindings with shallow comparison built in. */ +export interface ShallowStoreBindings = []> { + useStore: StoreValueHook; + useStorePlain: StorePlainHook; + useStoreApi: StoreApiWithMutators; +} diff --git a/src/types/store-hooks.types.ts b/src/types/store-hooks.types.ts new file mode 100644 index 0000000..5b3f012 --- /dev/null +++ b/src/types/store-hooks.types.ts @@ -0,0 +1,9 @@ +export interface StoreValueHook { + (): TState; + (selector: (state: TState) => T, equalityFn?: (left: T, right: T) => boolean): T; +} + +export interface StorePlainHook { + (): TState; + (selector: (state: TState) => T): T; +} diff --git a/src/providers/createStoreProvider.types.ts b/src/types/store-provider.types.ts similarity index 89% rename from src/providers/createStoreProvider.types.ts rename to src/types/store-provider.types.ts index a72f535..da5d541 100644 --- a/src/providers/createStoreProvider.types.ts +++ b/src/types/store-provider.types.ts @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; -import type { StorePlainHook, StoreValueHook } from "../hooks"; -import type { StoreApiWithMutators, StoreMutatorTuple } from "../types"; +import type { StorePlainHook, StoreValueHook } from "./store-hooks.types"; +import type { StoreApiWithMutators, StoreMutatorTuple } from "./store.types"; /** Configuration for store provider lifecycle. */ export interface StoreProviderConfig< diff --git a/src/core/createStoreToolkit.types.ts b/src/types/store-toolkit.types.ts similarity index 60% rename from src/core/createStoreToolkit.types.ts rename to src/types/store-toolkit.types.ts index 734bff9..897f03f 100644 --- a/src/core/createStoreToolkit.types.ts +++ b/src/types/store-toolkit.types.ts @@ -1,7 +1,6 @@ -import type { ResolvedStoreBindings } from "../hooks"; -import type { StoreProviderResult } from "../providers"; -import type { StoreMutatorTuple } from "../types"; -import type { ShallowStoreBindings } from "./createShallowStore.types"; +import type { ResolvedStoreBindings, ShallowStoreBindings } from "./store-bindings.types"; +import type { StoreProviderResult } from "./store-provider.types"; +import type { StoreMutatorTuple } from "./store.types"; /** Combined global, provider, and resolved store bindings. */ export interface StoreToolkit = []> diff --git a/src/types/store.ts b/src/types/store.types.ts similarity index 100% rename from src/types/store.ts rename to src/types/store.types.ts