Skip to content

Instantly share code, notes, and snippets.

@airportyh
Last active June 4, 2020 21:43
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save airportyh/4f01b49b18a71d3ca6a068752bddfef1 to your computer and use it in GitHub Desktop.
Save airportyh/4f01b49b18a71d3ca6a068752bddfef1 to your computer and use it in GitHub Desktop.
TypeScript-based store implementation with type inference that works with StrictNullChecks turned on.
import { Store } from "./store";
interface IUser {
username: string;
}
interface IProduct {
name: string;
description?: string;
price: number;
detail?: {
publishedYear: number,
otherStuff: string
}
}
interface IAppState {
user: IUser;
product?: IProduct;
}
let initialState: IAppState = {
user: {
username: "Tony"
},
product: {
name: "Soccer ball",
price: 15.99
}
};
let store: Store<IAppState> = new Store(initialState);
// The correct types for the below examples are inferred
let username$ = store.get(["user", "username"]);
let product$ = store.get(["product"]);
let productPrice$ = store.get(["product", "price"]);
let productDescription$ = store.get(["product", "description"]);
let productPublished$ = store.get(["product", "detail", "publishedYear"]);
username$.subscribe((value: string) => {
console.log("Got username", value);
});
store.set(["user", "username"], "blah");
store.set(["user"], { username: "Jess"});
store.set(["product", "detail", "publishedYear"], 1943);
import { Observable, BehaviorSubject, Subject } from "rxjs";
import { set, get } from "lodash/fp";
import { map, distinctUntilChanged } from "rxjs/operators";
// Diff is taken from https://www.typescriptlang.org/docs/handbook/advanced-types.html
// It removes types from T that are assignable to U. Think of it as "type subtraction".
// For example: if you had a union type:
// type FieldConfig = LookupFieldConfig | TextFieldConfig | ChoiceFieldConfig;
// then Diff<FieldConfig, LookupFieldConfig> would yield: TextFieldConfig | ChoiceFieldConfig.
type Diff<T, U> = T extends U ? never : T;
// NonNullParts<T> gives you a type that subtracts null and undefined from T. For example if your type T was
// IPerson | undefined, then NonNullParts<T> would yield: IPerson.
type NonNullParts<T> = Diff<T, null | undefined>;
// NullParts<T> gives you a type that contains just the null parts (null and undefined) of T.
// For example if your type T was IPerson | undefined, then NullParts<T> would yield: undefined.
// If your type U was IProduct, then NullParts<U> would yield: never.
type NullParts<T> = Diff<T, NonNullParts<T>>;
export class Store<T> {
subject$: BehaviorSubject<T>;
constructor(initialState: T) {
this.subject$ = new BehaviorSubject(initialState);
}
/*
The following type definitions bares some explanation, as the code is
dense and scary looking. The `get` method has 4 different signatures.
The last one is the fall through, which is picked if more than 3 keys are
used in the path argument. For case path.length is 3 or lower, developer
will enjoy TypeScript type inference feature which can automatically infer
the type within the observable that is returned base on the type T of the
state object.
*/
// Case path.length == 1. Uses keyof type to relate output type to T
// and the key within the path that was supplied.
get<K extends keyof T>(path: [K]): Observable<T[K]>;
// Case path.length == 2. Two keys are present. E.g.: `store.get(["user", "username"])`.
// The `NonNullParts` trick handles the case where the value under "user" could be
// null or undefined. It makes it possible to still key into the "non null part" or
// that type using the key `K2`. It is necessary because T[K1][K2] will be `never` in the
// event that T[K1] could be null or undefined.
// The type within the observable that is returned is a combination of the result
// of successfully accessing both K1 and K2 to get to the value at the leaf level,
// plus the NullParts that arise in the event that T[K1] is null or undefined.
get<
K1 extends keyof T,
K2 extends keyof NonNullParts<T[K1]>
>(path: [K1, K2]):
Observable<
NonNullParts<T[K1]>[K2] |
NullParts<T[K1]>
>;
// Case path.length == 3. This case is a straightforward extendion of the case of path.length == 2.
// See if you can read it and explain it.
get<
K1 extends keyof T,
K2 extends keyof NonNullParts<T[K1]>,
K3 extends keyof NonNullParts<NonNullParts<T[K1]>[K2]>
>(path: [K1, K2, K3]):
Observable<
NonNullParts<NonNullParts<T[K1]>[K2]>[K3] |
NullParts<NonNullParts<T[K1]>[K2]> |
NullParts<T[K1]>
>;
// Fallback "escape hatch" type definition and implementation. If you have a situation where
// the path.length > 4, TypeScript uses this. I though 3 is a good number to stop at because
// I didn't want to delve deeper into insanity.
get(path?: string[]): Observable<any> {
if (!path || path.length === 0) {
return this.subject$;
}
return this.subject$.pipe(
map((state) => get(path, state)),
distinctUntilChanged()
);
}
/*
The type signature of the `set` method follows the same pattern as those for `get`.
It does not have to worry about the return type, because it's return type is void.
All it has to do is make sure the value to be set conforms to the type based on the
passed in keys.
*/
// Case path.length == 1.
set<K extends keyof T>(path: [K], value: T[K]): void;
// Case path.length == 2. Again, we use the NonNullParts trick so that it is possible
// to define and access K2 from the non-null parts of T[K1].
set<
K1 extends keyof T,
K2 extends keyof NonNullParts<T[K1]>
>(path: [K1, K2], value: NonNullParts<T[K1]>[K2]): void;
// Case path.length == 3. Again, this is a straight-forward extension of case path.length == 2.
set<
K1 extends keyof T,
K2 extends keyof NonNullParts<T[K1]>,
K3 extends keyof NonNullParts<NonNullParts<T[K1]>[K2]>
>(path: [K1, K2, K3], value: NonNullParts<NonNullParts<T[K1]>[K2]>[K3]): void;
// Fall-through case.
set(path: string[], value: any): void {
const newValue = set(path, value, this.subject$.value);
this.subject$.next(newValue);
}
}
{
"compilerOptions": {
"strictNullChecks": true
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment