Skip to content

Commit

Permalink
Merge pull request #197 from bram209/fix-observable-types
Browse files Browse the repository at this point in the history
Fix observable typings when using optional and nullable values
  • Loading branch information
jmeistrich authored Sep 19, 2023
2 parents b696db0 + d7bbbaf commit 11fdb1b
Show file tree
Hide file tree
Showing 10 changed files with 282 additions and 75 deletions.
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/history/trackHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function trackHistory<T>(
obs: ObservableReadable<T>,
targetObservable?: ObservableWriteable<Record<TimestampAsString, Partial<T>>>,
) {
const history = targetObservable ?? observable<Record<TimestampAsString, Partial<T>>>();
const history = targetObservable ?? observable<Record<TimestampAsString, Partial<T>>>({});

obs.onChange(({ changes }) => {
// Don't save history if this is a remote change.
Expand Down
40 changes: 17 additions & 23 deletions src/observable.ts
Original file line number Diff line number Diff line change
@@ -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<T>(value: Promise<T>, makePrimitive?: true): ObservablePrimitive<T & PromiseInfo>;
function createObservable<T>(value?: T, makePrimitive?: true): ObservablePrimitive<T>;
function createObservable<T>(
value?: Promise<T>,
makePrimitive?: boolean,
): ObservablePrimitive<T & PromiseInfo> | ObservableObjectOrArray<T & PromiseInfo>;
function createObservable<T>(value?: T, makePrimitive?: boolean): ObservablePrimitive<T> | ObservableObjectOrArray<T> {
type MaybePromise<T> = NonNullable<T> extends Promise<infer U> ? (U & PromiseInfo) | Extract<T, undefined> : T;

function createObservable<T>(value: T, makePrimitive: true): ObservablePrimitive<MaybePromise<T>>;
function createObservable<T>(value: T, makePrimitive: false): Observable<MaybePromise<T>>;
function createObservable<T>(value: T, makePrimitive: boolean): Observable<T> | ObservablePrimitive<T> {
const valueIsPromise = isPromise<T>(value);
const root: ObservableRoot = {
_: value,
Expand All @@ -31,7 +23,7 @@ function createObservable<T>(value?: T, makePrimitive?: boolean): ObservablePrim

const obs = prim
? (new (ObservablePrimitiveClass as any)(node) as ObservablePrimitive<T>)
: (getProxy(node) as ObservableObjectOrArray<T>);
: (getProxy(node) as Observable<T>);

if (valueIsPromise) {
extractPromise(node, value);
Expand All @@ -40,14 +32,16 @@ function createObservable<T>(value?: T, makePrimitive?: boolean): ObservablePrim
return obs;
}

export function observable<T>(value: Promise<T>): Observable<T & PromiseInfo>;
export function observable<T>(value?: T): Observable<T>;
export function observable<T>(value?: T | Promise<T>): Observable<T & PromiseInfo> {
return createObservable(value) as Observable<T & PromiseInfo>;
export type MaybePromiseObservable<T> = Observable<MaybePromise<T>>;

export function observable<T>(): MaybePromiseObservable<T | undefined>;
export function observable<T>(value: T): MaybePromiseObservable<T>;
export function observable<T>(value?: T): MaybePromiseObservable<T | undefined> {
return createObservable(value, /*makePrimitive*/ false);
}

export function observablePrimitive<T>(value: Promise<T>): ObservablePrimitive<T & PromiseInfo>;
export function observablePrimitive<T>(value?: T): ObservablePrimitive<T>;
export function observablePrimitive<T>(value?: T | Promise<T>): ObservablePrimitive<T & PromiseInfo> {
return createObservable(value, /*makePrimitive*/ true) as ObservablePrimitive<T & PromiseInfo>;
export function observablePrimitive<T>(): ObservablePrimitive<MaybePromise<T | undefined>>;
export function observablePrimitive<T>(value: T): ObservablePrimitive<MaybePromise<T>>;
export function observablePrimitive<T>(value?: T): ObservablePrimitive<MaybePromise<T | undefined>> {
return createObservable(value, /*makePrimitive*/ true);
}
78 changes: 36 additions & 42 deletions src/observableInterfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ export interface ListenerParams<T = any> {
}
export type ListenerFn<T = any> = (params: ListenerParams<T>) => void;

type PrimitiveKeys<T> = Pick<T, { [K in keyof T]-?: T[K] extends Primitive ? K : never }[keyof T]>;
type NonPrimitiveKeys<T> = Pick<T, { [K in keyof T]-?: T[K] extends Primitive ? never : K }[keyof T]>;
type PrimitiveProps<T> = Pick<T, PrimitiveKeys<T>>;
type NonPrimitiveProps<T> = Omit<T, PrimitiveKeys<T>>;

type Recurse<T, K extends keyof T, TRecurse> = T[K] extends ObservableReadable
? T[K]
Expand Down Expand Up @@ -128,14 +128,20 @@ type Recurse<T, K extends keyof T, TRecurse> = T[K] extends ObservableReadable
? TRecurse
: T[K];

type ObservableFnsRecursiveUnsafe<T> = {
[K in keyof T]-?: Recurse<T, K, ObservableObject<NonNullable<T[K]>>>;
type ObservableFnsRecursiveUnsafe<T, NullableMarker> = {
[K in keyof T]-?: Recurse<T, K, ObservableObject<T[K], NullableMarker>>;
};
type ObservableFnsRecursiveSafe<T> = {
readonly [K in keyof T]-?: Recurse<T, K, ObservableObject<NonNullable<T[K]>>>;

type ObservableFnsRecursiveSafe<T, NullableMarker> = {
readonly [K in keyof T]-?: Recurse<T, K, ObservableObject<T[K], NullableMarker>>;
};

type MakeNullable<T, NullableMarker> = {
[K in keyof T]: T[K] | (NullableMarker extends never ? never : undefined);
};
type ObservableFnsRecursive<T> = ObservableFnsRecursiveSafe<NonPrimitiveKeys<T>> &
ObservableFnsRecursiveUnsafe<PrimitiveKeys<T>>;

type ObservableFnsRecursive<T, NullableMarker> = ObservableFnsRecursiveSafe<NonPrimitiveProps<T>, NullableMarker> &
ObservableFnsRecursiveUnsafe<MakeNullable<PrimitiveProps<T>, NullableMarker>, NullableMarker>;

type ObservableComputedFnsRecursive<T> = {
readonly [K in keyof T]-?: Recurse<T, K, ObservableBaseFns<NonNullable<T[K]>>>;
Expand Down Expand Up @@ -316,48 +322,31 @@ export type RecordValue<T> = T extends Record<string, infer t> ? t : never;
export type ArrayValue<T> = T extends Array<infer t> ? t : never;
export type ObservableValue<T> = T extends Observable<infer t> ? 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<T> = Pick<
T,
{
[K in keyof T]-?: K extends string
? T[K] extends Record<string, any>
? T[K] extends any[]
? never
: K
: never
: never;
}[keyof T]
>;
declare type DictKeys<T> = Pick<
T,
{
[K in keyof T]-?: K extends string ? (T[K] extends Record<string, Record<string, any>> ? K : never) : never;
}[keyof T]
>;
declare type ArrayKeys<T> = Pick<
T,
{
[K in keyof T]-?: K extends string | number ? (T[K] extends any[] ? K : never) : never;
}[keyof T]
>;
export declare type FieldTransforms<T> =
type FilterKeysByValue<T, U> = {
[K in keyof T]: T[K] extends U ? K & (string | number) : never;
}[keyof T];

type ObjectKeys<T> = Exclude<FilterKeysByValue<T, Record<string, any>>, FilterKeysByValue<T, any[]>>;
type DictKeys<T> = FilterKeysByValue<T, Record<string, Record<string, any>>>;
type ArrayKeys<T> = FilterKeysByValue<T, any[]>;
type PrimitiveKeys<T> = FilterKeysByValue<T, Primitive>;

export type FieldTransforms<T> =
| (T extends Record<string, Record<string, any>> ? { _dict: FieldTransformsInner<RecordValue<T>> } : never)
| FieldTransformsInner<T>;
export declare type FieldTransformsInner<T> = {
export type FieldTransformsInner<T> = {
[K in keyof T]: string;
} & (
| {
[K in keyof ObjectKeys<T> as `${K}_obj`]?: FieldTransforms<T[K]>;
[K in ObjectKeys<T> as `${K}_obj`]?: FieldTransforms<T[K]>;
}
| {
[K in keyof DictKeys<T> as `${K}_dict`]?: FieldTransforms<RecordValue<T[K]>>;
[K in DictKeys<T> as `${K}_dict`]?: FieldTransforms<RecordValue<T[K]>>;
}
) & {
[K in keyof ArrayKeys<T> as `${K}_arr`]?: FieldTransforms<ArrayValue<T[K]>>;
[K in ArrayKeys<T> as `${K}_arr`]?: FieldTransforms<ArrayValue<T[K]>>;
} & {
[K in keyof ArrayKeys<T> as `${K}_val`]?: FieldTransforms<ArrayValue<T[K]>>;
[K in ArrayKeys<T> as `${K}_val`]?: FieldTransforms<ArrayValue<T[K]>>;
};

export type Selector<T> = ObservableReadable<T> | ObservableEvent | (() => T) | T;
Expand All @@ -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> = T extends Primitive ? never : T;

export type ObservableMap<T extends Map<any, any> | WeakMap<any, any>> = Omit<T, 'get' | 'size'> &
Expand All @@ -383,7 +372,12 @@ export type ObservableSet<T extends Set<any> | WeakSet<any>> = Omit<T, 'size'> &
Omit<ObservablePrimitiveBaseFns<T>, 'size'> & { size: ObservablePrimitiveChild<number> };
export type ObservableMapIfMap<T> = T extends Map<any, any> | WeakMap<any, any> ? ObservableMap<T> : never;
export type ObservableArray<T extends any[]> = ObservableObjectFns<T> & ObservableArrayOverride<T[number]>;
export type ObservableObject<T = any> = ObservableFnsRecursive<T> & ObservableObjectFns<T>;
export type ObservableObject<T = any, NullableMarker = never> = ObservableFnsRecursive<
NonNullable<T>,
Extract<T | NullableMarker, null | undefined>
> &
ObservableObjectFns<T | (NullableMarker extends never ? never : undefined)>;

export type ObservableChild<T = any> = [T] extends [Primitive] ? ObservablePrimitiveChild<T> : ObservableObject<T>;
export type ObservablePrimitiveChild<T = any> = [T] extends [boolean]
? ObservablePrimitiveChildFns<T> & ObservablePrimitiveBooleanFns<T>
Expand Down
2 changes: 1 addition & 1 deletion src/persist-plugins/firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ class ObservablePersistFirebaseBase implements ObservablePersistRemoteClass {
protected fns: FirebaseFns;
private _pathsLoadStatus = observable<Record<string, LoadStatus>>({});
private SaveTimeout;
private user: Observable<string>;
private user: Observable<string | undefined>;
private listenErrors: Map<
any,
{
Expand Down
9 changes: 6 additions & 3 deletions src/react/useObservable.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,7 +9,9 @@ import { useMemo } from 'react';
*
* @see https://www.legendapp.com/dev/state/react/#useObservable
*/
export function useObservable<T>(initialValue?: T | (() => T) | (() => Promise<T>)): Observable<T> {
export function useObservable<T>(): MaybePromiseObservable<T | undefined>;
export function useObservable<T>(initialValue: T | (() => T)): MaybePromiseObservable<T>;
export function useObservable<T>(initialValue?: T | (() => T)): MaybePromiseObservable<T | undefined> {
// Create the observable from the default value
return useMemo(() => observable<T>((isFunction(initialValue) ? initialValue() : initialValue) as T), []);
return useMemo(() => observable(isFunction(initialValue) ? initialValue() : initialValue), []);
}
3 changes: 2 additions & 1 deletion src/react/useObservableReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ReducerWithoutAction,
} from 'react';
import { useObservable } from './useObservable';
import type { MaybePromiseObservable } from '../observable';

export function useObservableReducer<R extends ReducerWithoutAction<any>, I>(
reducer: R,
Expand Down Expand Up @@ -40,7 +41,7 @@ export function useObservableReducer<R extends Reducer<any, any>, I>(
reducer: R,
initializerArg: I & ReducerState<R>,
initializer: ((arg: I & ReducerState<R>) => ReducerState<R>) | undefined,
): [Observable<ReducerState<R>>, Dispatch<ReducerAction<R>>] {
): [MaybePromiseObservable<ReducerState<R>>, Dispatch<ReducerAction<R>>] {
const obs = useObservable(() =>
initializerArg !== undefined && isFunction(initializerArg) ? initializer!(initializerArg) : initializerArg,
);
Expand Down
6 changes: 3 additions & 3 deletions tests/tests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2195,7 +2195,7 @@ describe('Promise values', () => {
test('when callback works with promises', async () => {
let resolver: (value: number) => void;
const promise = new Promise<number>((resolve) => (resolver = resolve));
const obs = observable<number>(promise);
const obs = observable(promise);
let didWhen = false;
when(obs, () => {
didWhen = true;
Expand All @@ -2208,7 +2208,7 @@ describe('Promise values', () => {
test('when works with promises', async () => {
let resolver: (value: number) => void;
const promise = new Promise<number>((resolve) => (resolver = resolve));
const obs = observable<number>(promise);
const obs = observable(promise);
let didWhen = false;
when(obs).then(() => {
didWhen = true;
Expand Down Expand Up @@ -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' });
Expand Down
Loading

0 comments on commit 11fdb1b

Please sign in to comment.