diff --git a/package.json b/package.json index 2eb258e..0adb08b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jods", - "version": "1.1.3", + "version": "1.1.4", "description": "A minimal, reactive JSON state layer for Node.js and the browser", "type": "module", "main": "./dist/index.js", diff --git a/src/core/computed.ts b/src/core/computed.ts index a750073..c8675d9 100644 --- a/src/core/computed.ts +++ b/src/core/computed.ts @@ -12,15 +12,39 @@ export const COMPUTED_SYMBOL = Symbol("computed"); export const COMPUTED_IS_DIRTY = Symbol("computedIsDirty"); /** - * Interface for computed value object + * Brand symbol for computed values (compile-time identification) + * @internal */ -export interface ComputedValue { +declare const ComputedBrand: unique symbol; + +/** + * Interface for computed value object. + * + * IMPORTANT: This type extends T so that ComputedValue is assignable to number, + * enabling excellent DX where computed properties can be used like regular values: + * + * @example + * ```ts + * interface State { doubled?: ComputedValue; } + * const s = store({ count: 5 }); + * s.doubled = computed(() => s.count * 2); + * console.log(s.doubled + 10); // ✅ Works! TypeScript sees this as number + * ``` + */ +export type ComputedValue = T & { + /** Call signature - computed values are callable */ (storeInstance?: S): T; - [COMPUTED_SYMBOL]: true; + /** @internal Runtime symbol for identifying computed properties */ + [COMPUTED_SYMBOL]?: true; + /** @internal Runtime symbol for dirty tracking */ [COMPUTED_IS_DIRTY]?: boolean; + /** @internal Method to mark the computed as needing recalculation */ markDirty?: () => void; - __debugName?: string; // For debugging -} + /** @internal Debug name for development */ + __debugName?: string; + /** @internal Brand for type identification */ + readonly [ComputedBrand]?: true; +}; /** * Creates a computed property that will recalculate its value diff --git a/src/core/store/index.ts b/src/core/store/index.ts index 6c8cf1d..6945704 100644 --- a/src/core/store/index.ts +++ b/src/core/store/index.ts @@ -9,6 +9,21 @@ export { /** * Creates a reactive store with fine-grained updates via signals. + * + * **Note on Computed Values:** + * When you define a store interface with `ComputedValue`, TypeScript sees + * the property as `ComputedValue`. However, at runtime, accessing the property + * returns `T` directly (the proxy unwraps it). To get proper typing when reading + * computed values, use one of these approaches: + * + * 1. Use `json(store)` which returns all values as plain types + * 2. Define computed properties as their result type and cast when assigning: + * ```ts + * interface State { doubled?: number; } + * s.doubled = computed(() => s.count * 2) as any; + * ``` + * 3. Call the computed as a function: `s.doubled()` (works at runtime) + * * @param initialState Initial store state * @param options Configuration options * @returns Mutable proxy object with Store interface diff --git a/src/core/store/state.ts b/src/core/store/state.ts index fbd42a0..1a9884f 100644 --- a/src/core/store/state.ts +++ b/src/core/store/state.ts @@ -226,7 +226,8 @@ export function initializeStateAndSignals( }); } } else { - signals.set(key, createSignal(initialValue)); + // Cast to any to handle complex type inference with ComputedValue extending T + signals.set(key, createSignal(initialValue as any)); } } } diff --git a/src/frameworks/preact/useJodsPreact.ts b/src/frameworks/preact/useJodsPreact.ts index 1fd13c1..ebeec5f 100644 --- a/src/frameworks/preact/useJodsPreact.ts +++ b/src/frameworks/preact/useJodsPreact.ts @@ -16,7 +16,8 @@ function createComputedProxy( // If the property is a computed value, automatically invoke it with store if (isComputed(value)) { // Call the computed property with the store as the this context - return value.call(store); + // Cast needed because ComputedValue extends T for DX, but is still callable + return (value as unknown as (this: typeof store) => unknown).call(store); } return value; }, diff --git a/src/frameworks/react/useJods.ts b/src/frameworks/react/useJods.ts index 04219c3..bf47492 100644 --- a/src/frameworks/react/useJods.ts +++ b/src/frameworks/react/useJods.ts @@ -16,7 +16,8 @@ function createComputedProxy( // If the property is a computed value, automatically invoke it with store as 'this' context if (isComputed(value)) { // Call the computed property with the store as the this context - return value.call(store); + // Cast needed because ComputedValue extends T for DX, but is still callable + return (value as unknown as (this: typeof store) => unknown).call(store); } return value; }, diff --git a/src/index.ts b/src/index.ts index 14057ce..0f1a766 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,4 +12,6 @@ export type { Unsubscribe, ComputeFunction, DiffResult, + UnwrapComputedValue, + UnwrapComputedStore, } from "./types"; diff --git a/src/types/index.ts b/src/types/index.ts index 1e459d3..18b1c5e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,10 +17,54 @@ export type Unsubscribe = () => void; export type ComputeFunction = () => T; /** - * Computed value type + * Brand symbol for computed values (compile-time only) + * @internal + */ +declare const ComputedBrand: unique symbol; + +/** + * Computed value type - designed for excellent DX! + * + * This type is structured so that `ComputedValue` is assignable to `T`, + * meaning you can use computed properties just like regular values: + * + * @example + * ```ts + * interface State { + * count: number; + * doubled?: ComputedValue; + * } + * const s = store({ count: 5 }); + * s.doubled = computed(() => s.count * 2); + * + * // These all work with proper types! + * console.log(s.doubled + 10); // ✅ number operations work + * console.log(s.doubled.toFixed(2)); // ✅ number methods work + * ``` + * * @public */ -export type ComputedValue = ComputeFunction; +export type ComputedValue = T & { + /** @internal Brand to identify computed values */ + readonly [ComputedBrand]?: true; + /** @internal The compute function (also callable at runtime) */ + (): T; +}; + +/** + * Utility type to unwrap ComputedValue to T + * Used internally by Store types to provide correct access types + * @public + */ +export type UnwrapComputedValue = T extends ComputedValue ? U : T; + +/** + * Utility type to unwrap all ComputedValue properties in an object type + * @public + */ +export type UnwrapComputedStore = { + [K in keyof T]: UnwrapComputedValue; +}; /** * Signal read function type