diff --git a/package-lock.json b/package-lock.json index f623776b4..9f6196e98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "eslint": "^8.45.0", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", + "expect-type": "^0.16.0", "fake-indexeddb": "^4.0.0", "firebase": "^10.3.0", "jest": "^29.6.1", @@ -9939,6 +9940,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expect-type": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-0.16.0.tgz", + "integrity": "sha512-wCpFeVBiAPGiYkQZzaqvGuuBnNCHbtnowMOBpBGY8a27XbG8VAit3lklWph1r8VmgsH61mOZqI3NuGm8bZnUlw==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend-shallow": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", @@ -27811,6 +27821,12 @@ "jest-util": "^29.6.1" } }, + "expect-type": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-0.16.0.tgz", + "integrity": "sha512-wCpFeVBiAPGiYkQZzaqvGuuBnNCHbtnowMOBpBGY8a27XbG8VAit3lklWph1r8VmgsH61mOZqI3NuGm8bZnUlw==", + "dev": true + }, "extend-shallow": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", diff --git a/package.json b/package.json index 511b4e71e..8933227e3 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "lint:check": "eslint ./src ./tests --ext .ts,.tsx", "format:write": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", "format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", - "release": "release-it" + "release": "release-it", + "typecheck": "tsc --noEmit" }, "exports": { "./package.json": "./package.json", @@ -119,6 +120,7 @@ "eslint": "^8.45.0", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", + "expect-type": "^0.16.0", "fake-indexeddb": "^4.0.0", "firebase": "^10.3.0", "jest": "^29.6.1", diff --git a/src/history/trackHistory.ts b/src/history/trackHistory.ts index a57364703..2d6625f5b 100644 --- a/src/history/trackHistory.ts +++ b/src/history/trackHistory.ts @@ -14,7 +14,7 @@ export function trackHistory( obs: ObservableReadable, targetObservable?: ObservableWriteable>>, ) { - const history = targetObservable ?? observable>>(); + const history = targetObservable ?? observable>>({}); obs.onChange(({ changes }) => { // Don't save history if this is a remote change. diff --git a/src/observable.ts b/src/observable.ts index d7dd28f87..964ca73ce 100644 --- a/src/observable.ts +++ b/src/observable.ts @@ -1,22 +1,14 @@ import { extractPromise, getProxy } from './ObservableObject'; import { ObservablePrimitiveClass } from './ObservablePrimitive'; import { isActualPrimitive, isPromise } from './is'; -import type { - Observable, - ObservableObjectOrArray, - ObservablePrimitive, - ObservableRoot, - PromiseInfo, -} from './observableInterfaces'; +import type { Observable, ObservablePrimitive, ObservableRoot, PromiseInfo } from './observableInterfaces'; import { NodeValue } from './observableInterfaces'; -function createObservable(value: Promise, makePrimitive?: true): ObservablePrimitive; -function createObservable(value?: T, makePrimitive?: true): ObservablePrimitive; -function createObservable( - value?: Promise, - makePrimitive?: boolean, -): ObservablePrimitive | ObservableObjectOrArray; -function createObservable(value?: T, makePrimitive?: boolean): ObservablePrimitive | ObservableObjectOrArray { +type MaybePromise = NonNullable extends Promise ? (U & PromiseInfo) | Extract : T; + +function createObservable(value: T, makePrimitive: true): ObservablePrimitive>; +function createObservable(value: T, makePrimitive: false): Observable>; +function createObservable(value: T, makePrimitive: boolean): Observable | ObservablePrimitive { const valueIsPromise = isPromise(value); const root: ObservableRoot = { _: value, @@ -31,7 +23,7 @@ function createObservable(value?: T, makePrimitive?: boolean): ObservablePrim const obs = prim ? (new (ObservablePrimitiveClass as any)(node) as ObservablePrimitive) - : (getProxy(node) as ObservableObjectOrArray); + : (getProxy(node) as Observable); if (valueIsPromise) { extractPromise(node, value); @@ -40,14 +32,16 @@ function createObservable(value?: T, makePrimitive?: boolean): ObservablePrim return obs; } -export function observable(value: Promise): Observable; -export function observable(value?: T): Observable; -export function observable(value?: T | Promise): Observable { - return createObservable(value) as Observable; +export type MaybePromiseObservable = Observable>; + +export function observable(): MaybePromiseObservable; +export function observable(value: T): MaybePromiseObservable; +export function observable(value?: T): MaybePromiseObservable { + return createObservable(value, /*makePrimitive*/ false); } -export function observablePrimitive(value: Promise): ObservablePrimitive; -export function observablePrimitive(value?: T): ObservablePrimitive; -export function observablePrimitive(value?: T | Promise): ObservablePrimitive { - return createObservable(value, /*makePrimitive*/ true) as ObservablePrimitive; +export function observablePrimitive(): ObservablePrimitive>; +export function observablePrimitive(value: T): ObservablePrimitive>; +export function observablePrimitive(value?: T): ObservablePrimitive> { + return createObservable(value, /*makePrimitive*/ true); } diff --git a/src/observableInterfaces.ts b/src/observableInterfaces.ts index 797fb3d82..6065bedf0 100644 --- a/src/observableInterfaces.ts +++ b/src/observableInterfaces.ts @@ -97,8 +97,8 @@ export interface ListenerParams { } export type ListenerFn = (params: ListenerParams) => void; -type PrimitiveKeys = Pick; -type NonPrimitiveKeys = Pick; +type PrimitiveProps = Pick>; +type NonPrimitiveProps = Omit>; type Recurse = T[K] extends ObservableReadable ? T[K] @@ -128,14 +128,20 @@ type Recurse = T[K] extends ObservableReadable ? TRecurse : T[K]; -type ObservableFnsRecursiveUnsafe = { - [K in keyof T]-?: Recurse>>; +type ObservableFnsRecursiveUnsafe = { + [K in keyof T]-?: Recurse>; }; -type ObservableFnsRecursiveSafe = { - readonly [K in keyof T]-?: Recurse>>; + +type ObservableFnsRecursiveSafe = { + readonly [K in keyof T]-?: Recurse>; +}; + +type MakeNullable = { + [K in keyof T]: T[K] | (NullableMarker extends never ? never : undefined); }; -type ObservableFnsRecursive = ObservableFnsRecursiveSafe> & - ObservableFnsRecursiveUnsafe>; + +type ObservableFnsRecursive = ObservableFnsRecursiveSafe, NullableMarker> & + ObservableFnsRecursiveUnsafe, NullableMarker>, NullableMarker>; type ObservableComputedFnsRecursive = { readonly [K in keyof T]-?: Recurse>>; @@ -316,48 +322,31 @@ export type RecordValue = T extends Record ? t : never; export type ArrayValue = T extends Array ? t : never; export type ObservableValue = T extends Observable ? t : never; -// This converts the state object's shape to the field transformer's shape -// TODO: FieldTransformer and this shape can likely be refactored to be simpler -declare type ObjectKeys = Pick< - T, - { - [K in keyof T]-?: K extends string - ? T[K] extends Record - ? T[K] extends any[] - ? never - : K - : never - : never; - }[keyof T] ->; -declare type DictKeys = Pick< - T, - { - [K in keyof T]-?: K extends string ? (T[K] extends Record> ? K : never) : never; - }[keyof T] ->; -declare type ArrayKeys = Pick< - T, - { - [K in keyof T]-?: K extends string | number ? (T[K] extends any[] ? K : never) : never; - }[keyof T] ->; -export declare type FieldTransforms = +type FilterKeysByValue = { + [K in keyof T]: T[K] extends U ? K & (string | number) : never; +}[keyof T]; + +type ObjectKeys = Exclude>, FilterKeysByValue>; +type DictKeys = FilterKeysByValue>>; +type ArrayKeys = FilterKeysByValue; +type PrimitiveKeys = FilterKeysByValue; + +export type FieldTransforms = | (T extends Record> ? { _dict: FieldTransformsInner> } : never) | FieldTransformsInner; -export declare type FieldTransformsInner = { +export type FieldTransformsInner = { [K in keyof T]: string; } & ( | { - [K in keyof ObjectKeys as `${K}_obj`]?: FieldTransforms; + [K in ObjectKeys as `${K}_obj`]?: FieldTransforms; } | { - [K in keyof DictKeys as `${K}_dict`]?: FieldTransforms>; + [K in DictKeys as `${K}_dict`]?: FieldTransforms>; } ) & { - [K in keyof ArrayKeys as `${K}_arr`]?: FieldTransforms>; + [K in ArrayKeys as `${K}_arr`]?: FieldTransforms>; } & { - [K in keyof ArrayKeys as `${K}_val`]?: FieldTransforms>; + [K in ArrayKeys as `${K}_val`]?: FieldTransforms>; }; export type Selector = ObservableReadable | ObservableEvent | (() => T) | T; @@ -373,7 +362,7 @@ export interface ObservableRoot { activate?: () => void; } -export type Primitive = boolean | string | number | Date; +export type Primitive = boolean | string | number | Date | undefined | null | symbol | bigint; export type NotPrimitive = T extends Primitive ? never : T; export type ObservableMap | WeakMap> = Omit & @@ -383,7 +372,12 @@ export type ObservableSet | WeakSet> = Omit & Omit, 'size'> & { size: ObservablePrimitiveChild }; export type ObservableMapIfMap = T extends Map | WeakMap ? ObservableMap : never; export type ObservableArray = ObservableObjectFns & ObservableArrayOverride; -export type ObservableObject = ObservableFnsRecursive & ObservableObjectFns; +export type ObservableObject = ObservableFnsRecursive< + NonNullable, + Extract +> & + ObservableObjectFns; + export type ObservableChild = [T] extends [Primitive] ? ObservablePrimitiveChild : ObservableObject; export type ObservablePrimitiveChild = [T] extends [boolean] ? ObservablePrimitiveChildFns & ObservablePrimitiveBooleanFns diff --git a/src/persist-plugins/firebase.ts b/src/persist-plugins/firebase.ts index 46d6abf57..a3d063312 100644 --- a/src/persist-plugins/firebase.ts +++ b/src/persist-plugins/firebase.ts @@ -120,7 +120,7 @@ class ObservablePersistFirebaseBase implements ObservablePersistRemoteClass { protected fns: FirebaseFns; private _pathsLoadStatus = observable>({}); private SaveTimeout; - private user: Observable; + private user: Observable; private listenErrors: Map< any, { diff --git a/src/react/useObservable.ts b/src/react/useObservable.ts index b2c89fc8b..c04e00119 100644 --- a/src/react/useObservable.ts +++ b/src/react/useObservable.ts @@ -1,5 +1,6 @@ -import { isFunction, observable, Observable } from '@legendapp/state'; +import { isFunction, observable } from '@legendapp/state'; import { useMemo } from 'react'; +import type { MaybePromiseObservable } from '../observable'; /** * A React hook that creates a new observable @@ -8,7 +9,9 @@ import { useMemo } from 'react'; * * @see https://www.legendapp.com/dev/state/react/#useObservable */ -export function useObservable(initialValue?: T | (() => T) | (() => Promise)): Observable { +export function useObservable(): MaybePromiseObservable; +export function useObservable(initialValue: T | (() => T)): MaybePromiseObservable; +export function useObservable(initialValue?: T | (() => T)): MaybePromiseObservable { // Create the observable from the default value - return useMemo(() => observable((isFunction(initialValue) ? initialValue() : initialValue) as T), []); + return useMemo(() => observable(isFunction(initialValue) ? initialValue() : initialValue), []); } diff --git a/src/react/useObservableReducer.ts b/src/react/useObservableReducer.ts index 1809924b5..ed4d6bd4e 100644 --- a/src/react/useObservableReducer.ts +++ b/src/react/useObservableReducer.ts @@ -10,6 +10,7 @@ import type { ReducerWithoutAction, } from 'react'; import { useObservable } from './useObservable'; +import type { MaybePromiseObservable } from '../observable'; export function useObservableReducer, I>( reducer: R, @@ -40,7 +41,7 @@ export function useObservableReducer, I>( reducer: R, initializerArg: I & ReducerState, initializer: ((arg: I & ReducerState) => ReducerState) | undefined, -): [Observable>, Dispatch>] { +): [MaybePromiseObservable>, Dispatch>] { const obs = useObservable(() => initializerArg !== undefined && isFunction(initializerArg) ? initializer!(initializerArg) : initializerArg, ); diff --git a/tests/tests.test.ts b/tests/tests.test.ts index a3634a970..3d9822d8b 100644 --- a/tests/tests.test.ts +++ b/tests/tests.test.ts @@ -2195,7 +2195,7 @@ describe('Promise values', () => { test('when callback works with promises', async () => { let resolver: (value: number) => void; const promise = new Promise((resolve) => (resolver = resolve)); - const obs = observable(promise); + const obs = observable(promise); let didWhen = false; when(obs, () => { didWhen = true; @@ -2208,7 +2208,7 @@ describe('Promise values', () => { test('when works with promises', async () => { let resolver: (value: number) => void; const promise = new Promise((resolve) => (resolver = resolve)); - const obs = observable(promise); + const obs = observable(promise); let didWhen = false; when(obs).then(() => { didWhen = true; @@ -2564,7 +2564,7 @@ describe('Locking', () => { }); describe('Primitive <-> Object', () => { test('Starting as undefined', () => { - const obs = observable<{ test: string }>(undefined); + const obs = observable<{ test: string } | undefined>(undefined); expect(obs.get()).toEqual(undefined); obs.set({ test: 'hi' }); expect(obs.get()).toEqual({ test: 'hi' }); diff --git a/tests/types.test.ts b/tests/types.test.ts new file mode 100644 index 000000000..eed5a9c65 --- /dev/null +++ b/tests/types.test.ts @@ -0,0 +1,197 @@ +import { expectTypeOf } from 'expect-type'; +import { observable } from '../src/observable'; +import { + ObservableArray, + Observable, + ObservableObject, + ObservablePrimitive, + PromiseInfo, +} from '../src/observableInterfaces'; + +describe('Types', () => { + describe('observable', () => { + it('optional return type when no argument is passed', () => { + function noArgs() { + return observable(); + } + + type ObservableFn = ReturnType; + expectTypeOf().returns.toEqualTypeOf(); + }); + + it('optional return type when optional argument is passed', () => { + function withOptionalArg(something?: string) { + return observable(something); + } + + type ObservableFn = ReturnType; + expectTypeOf().returns.toEqualTypeOf(); + }); + + it('return type with promise info when promise is passed', () => { + function withPromise() { + return observable(Promise.resolve('foo')); + } + + type ObservableFn = ReturnType; + expectTypeOf().returns.toEqualTypeOf(); + }); + + it('optional return type with promise info when promise with optional value is passed', () => { + function withOptionalPromiseValue(something?: Promise) { + return observable(something); + } + + type ObservableFn = ReturnType; + expectTypeOf().returns.toEqualTypeOf<(string & PromiseInfo) | undefined>(); + }); + + it('issue #151', () => { + type ObservableFn = ReturnType< + typeof observable<{ + optional?: { foo: string }; + nullable: { foo: string } | null; + }> + >; + + expectTypeOf().returns.toEqualTypeOf<{ + optional?: { foo: string }; + nullable: { foo: string } | null; + }>(); + + // Note that if a parent is nullable, the child is optional (undefined) + expectTypeOf().returns.toEqualTypeOf(); + expectTypeOf().returns.toEqualTypeOf(); + }); + }); + + describe('Observable', () => { + describe('with state primitive', () => { + it('should infer string', () => { + type GetState = Observable['get']; + expectTypeOf().returns.toBeString(); + }); + + it('should infer number', () => { + type GetState = Observable['get']; + expectTypeOf().returns.toBeNumber(); + }); + + it('should infer boolean', () => { + type GetState = Observable['get']; + expectTypeOf().returns.toBeBoolean(); + }); + + it('should infer null', () => { + type GetState = Observable['get']; + expectTypeOf().returns.toBeNull(); + }); + + it('should infer undefined', () => { + type GetState = Observable['get']; + expectTypeOf().returns.toBeUndefined(); + }); + }); + + describe('with state object', () => { + it('should infer object', () => { + type State = Observable<{ foo: string }>; + expectTypeOf().toMatchTypeOf>(); + expectTypeOf().not.toMatchTypeOf>(); + expectTypeOf().not.toMatchTypeOf>(); + expectTypeOf().returns.toBeObject(); + }); + + describe('with nested nullable types', () => { + it('should infer nested nullable value', () => { + type State = Observable<{ foo: { bar: string | null } }>; + expectTypeOf().returns.toEqualTypeOf(); + }); + + it('should infer nested optional value', () => { + type State = Observable<{ foo: { bar?: string } }>; + expectTypeOf().returns.toEqualTypeOf(); + }); + + it('should infer nested value as optional if parent is nullable', () => { + type State = Observable<{ foo: { bar: string } | null }>; + expectTypeOf().returns.toEqualTypeOf(); + }); + + it('should infer nested value as optional if parent is optional', () => { + type State = Observable<{ foo?: { bar: string } }>; + expectTypeOf().returns.toEqualTypeOf(); + }); + + it('should infer nested value as optional if their ancestors are optional and nullable', () => { + type State = Observable<{ foo?: { bar: { value: number } | null } }>; + expectTypeOf().returns.toEqualTypeOf(); + }); + + it('should infer nullable value as both nullable and optional if parent is nullable', () => { + type State = Observable<{ foo: { bar?: string } | null }>; + expectTypeOf().returns.toEqualTypeOf(); + }); + + it('should infer nullable value as both nullable and optional if parent is optional', () => { + type State = Observable<{ foo?: { bar: string | null } }>; + expectTypeOf().returns.toEqualTypeOf(); + }); + }); + + describe('with nested state primitive', () => { + it('should infer string', () => { + type GetState = Observable<{ foo: string }>['foo']['get']; + expectTypeOf().returns.toBeString(); + }); + + it('should infer number', () => { + type GetState = Observable<{ foo: number }>['foo']['get']; + expectTypeOf().returns.toBeNumber(); + }); + + it('should infer boolean', () => { + type GetState = Observable<{ foo: boolean }>['foo']['get']; + expectTypeOf().returns.toBeBoolean(); + }); + + it('should infer null', () => { + type GetState = Observable<{ foo: null }>['foo']['get']; + expectTypeOf().returns.toBeNull(); + }); + + it('should infer undefined', () => { + type GetState = Observable<{ foo: undefined }>['foo']['get']; + expectTypeOf().returns.toBeUndefined(); + }); + }); + }); + + it('should infer array', () => { + type GetState = Observable<{ foo: 'bar' }[]>; + expectTypeOf().toMatchTypeOf>(); + }); + + it('should infer Map', () => { + type GetState = Observable>['get']; + expectTypeOf().returns.toEqualTypeOf>(); + }); + + describe('with maybe undefined', () => { + it('with primitive', () => { + type GetState = Observable['get']; + expectTypeOf().returns.toEqualTypeOf(); + }); + + it('with object', () => { + type GetState = Observable<{ foo: string } | undefined>['get']; + expectTypeOf().returns.toEqualTypeOf<{ foo: string } | undefined>(); + }); + + it('with array', () => { + type GetState = Observable<{ foo: string }[] | undefined>['get']; + expectTypeOf().returns.toEqualTypeOf<{ foo: string }[] | undefined>(); + }); + }); + }); +});