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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
34 changes: 29 additions & 5 deletions src/core/computed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = any, S = any> {
declare const ComputedBrand: unique symbol;

/**
* Interface for computed value object.
*
* IMPORTANT: This type extends T so that ComputedValue<number> is assignable to number,
* enabling excellent DX where computed properties can be used like regular values:
*
* @example
* ```ts
* interface State { doubled?: ComputedValue<number>; }
* const s = store<State>({ count: 5 });
* s.doubled = computed(() => s.count * 2);
* console.log(s.doubled + 10); // ✅ Works! TypeScript sees this as number
* ```
*/
export type ComputedValue<T = any, S = any> = 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
Expand Down
15 changes: 15 additions & 0 deletions src/core/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`, TypeScript sees
* the property as `ComputedValue<T>`. 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
Expand Down
3 changes: 2 additions & 1 deletion src/core/store/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@ export function initializeStateAndSignals<T extends StoreState>(
});
}
} else {
signals.set(key, createSignal(initialValue));
// Cast to any to handle complex type inference with ComputedValue<T> extending T
signals.set(key, createSignal(initialValue as any));
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/frameworks/preact/useJodsPreact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ function createComputedProxy<T extends object>(
// 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<T> extends T for DX, but is still callable
return (value as unknown as (this: typeof store) => unknown).call(store);
}
return value;
},
Expand Down
3 changes: 2 additions & 1 deletion src/frameworks/react/useJods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ function createComputedProxy<T extends object>(
// 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<T> extends T for DX, but is still callable
return (value as unknown as (this: typeof store) => unknown).call(store);
}
return value;
},
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ export type {
Unsubscribe,
ComputeFunction,
DiffResult,
UnwrapComputedValue,
UnwrapComputedStore,
} from "./types";
48 changes: 46 additions & 2 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,54 @@ export type Unsubscribe = () => void;
export type ComputeFunction<T = any> = () => 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<T>` is assignable to `T`,
* meaning you can use computed properties just like regular values:
*
* @example
* ```ts
* interface State {
* count: number;
* doubled?: ComputedValue<number>;
* }
* const s = store<State>({ 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<T = any> = ComputeFunction<T>;
export type ComputedValue<T = any> = T & {
/** @internal Brand to identify computed values */
readonly [ComputedBrand]?: true;
/** @internal The compute function (also callable at runtime) */
(): T;
};

/**
* Utility type to unwrap ComputedValue<T> to T
* Used internally by Store types to provide correct access types
* @public
*/
export type UnwrapComputedValue<T> = T extends ComputedValue<infer U> ? U : T;

/**
* Utility type to unwrap all ComputedValue properties in an object type
* @public
*/
export type UnwrapComputedStore<T> = {
[K in keyof T]: UnwrapComputedValue<T[K]>;
};

/**
* Signal read function type
Expand Down
Loading