Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[atom] Path checking #87

Closed
acarabott opened this issue May 30, 2019 · 8 comments
Closed

[atom] Path checking #87

acarabott opened this issue May 30, 2019 · 8 comments

Comments

@acarabott
Copy link
Contributor

acarabott commented May 30, 2019

A common issue I run into with Views, Watches, and Cursors is renaming the property on the underlying Atom, e.g.

Before renaming:

const state = new Atom({ x: 4 });
const view = new View(state, "x", x => x * 10);

After renaming

const state = new Atom({ value: 4 });
const view = new View(state, "x", x => x * 10); // now invalid!

As Paths are not simple strings, which could use the keyof type, I realise that this might not be trivial to solve, and could add overhead. Perhaps there is a strategy to overcome this?

Because of the same problem with resetIn I use this function, which provides a type safe reset with minimal syntax.

const reset = <T>(state: Atom<T>, update: Partial<T>) => {
  state.reset({ ...state.deref(), ...update });
};

const state = new Atom({ x: 4 });
reset(state, { x: 6 });
@earthlyreason
Copy link
Contributor

earthlyreason commented May 30, 2019

@acarabott it's definitely impossible for individual strings. (There was some discussion about composite string types in a TypeScript issue but I don't think it would ever become a priority.)

For array paths it's possible. After many searches and failed attempts, this is what I use:

// Types for checking path tuples against known types.

// Choosing `number` for array types is more in line with expected behavior.
// Otherwise the keys of array types would be those of the prototype methods.
// Intersecting with `PropertyKey` causes literals (instead of `string`) to be
// inferred at call sites, which narrows the search when used in path checking.
type KeyOf<T> = PropertyKey & (T extends any[] ? number : keyof T);
type KO<T> = KeyOf<T>;

/** Get the child item type of `T`, either its array element type or the type at
 * property `K` This is the counterpart to `KeyOf` for drilling paths. */
type TypeAt<T, K> = T extends (infer E)[]
  ? E
  : K extends keyof T
  ? T[K]
  : unknown;

// Shorthands
// Remove `null` and `undefined` at bottom so that paths don't dead-end.
type T1<T, A> = NonNullable<TypeAt<T, A>>;
type T2<T, A, B> = T1<T1<T, A>, B>;
type T3<T, A, B, C> = T1<T2<T, A, B>, C>;
type T4<T, A, B, C, D> = T1<T3<T, A, B, C>, D>;
type T5<T, A, B, C, D, E> = T1<T4<T, A, B, C, D>, E>;
type T6<T, A, B, C, D, E, F> = T1<T5<T, A, B, C, D, E>, F>;
type T7<T, A, B, C, D, E, F, G> = T1<T6<T, A, B, C, D, E, F>, G>;
type T8<T, A, B, C, D, E, F, G, H> = T1<T7<T, A, B, C, D, E, F, G>, H>;
type T9<T, A, B, C, D, E, F, G, H, I> = T1<T8<T, A, B, C, D, E, F, G, H>, I>;

along with

/** Return the given arguments as an array, typechecking only if they comprise a
 * key sequence defined in the structure of `T`.  This supports the use of
 * call-site inference to assert the validity of literal path expressions.  As
 * such, this must be used in conjunction with an implementation, which is
 * essentially a variadic identity function.  The extension type `X` can be
 * included to add compile-time verification that the output is from a
 * well-known implementor. */
export interface PathChecker<T, X = any> {
  <A extends KO<T>>(a: A): [A] & X;

  <A extends KO<T>, B extends KO<T1<T, A>>>(a: A, b: B): [A, B] & X;

  <A extends KO<T>, B extends KO<T1<T, A>>, C extends KO<T2<T, A, B>>>(
    a: A,
    b: B,
    c: C
  ): [A, B, C] & X;

  <
    A extends KO<T>,
    B extends KO<T1<T, A>>,
    C extends KO<T2<T, A, B>>,
    D extends KO<T3<T, A, B, C>>
  >(
    a: A,
    b: B,
    c: C,
    d: D
  ): [A, B, C, D] & X;
  // etc...
}

With that, you can say, for example

interface Example {
  description: string;
  people: Array<{
    name: string;
    age: number;
    sites: Array<{
      url: string;
    }>;
  }>;
}

const checker: PathChecker<Example> = (...args) => args;
const foo1 = checker("description"); // ✓
const foo2 = checker("people"); // ✓
const foo2_no = checker("persons"); // X Argument of type '"persons"' is not assignable to parameter of type '"description" | "people"'. [2345]
const foo3 = checker("people", 1); // ✓
const foo3_no = checker("people", "joe"); // X Argument of type '"joe"' is not assignable to parameter of type 'number'. [2345]
const foo4 = checker("people", 1, "name"); // ✓
const foo5 = checker("people", 1, "sites"); // ✓
const foo6 = checker("people", 1, "sites", 0); // ✓
const foo7 = checker("people", 1, "sites", 0, "url"); // ✓

Something like checker can be used in place of literal arrays where the type is known. This approach generally can also be applied to other uses. For example, you can also do a "getter" that infers the type from a given argument and returns the type at the path

/** For a given object, accept a sequence of valid keys as arguments and return
 * the value at the target location. */
export interface PathGetter {
  <T, A extends KO<T>>(t: T, a: A): T1<T, A>;

  <T, A extends KO<T>, B extends KO<T1<T, A>>>(t: T, a: A, b: B): T2<T, A, B>;

  <T, A extends KO<T>, B extends KO<T1<T, A>>, C extends KO<T2<T, A, B>>>(
    t: T,
    a: A,
    b: B,
    c: C
  ): T3<T, A, B, C>;

  // 4-ary
  <
    T,
    A extends KO<T>,
    B extends KO<T1<T, A>>,
    C extends KO<T2<T, A, B>>,
    D extends KO<T3<T, A, B, C>>
  >(
    t: T,
    a: A,
    b: B,
    c: C,
    d: D
  ): T4<T, A, B, C, D>;
  // etc...
}

With that, you can say

export const path: PathGetter = (value, ...keys) => {
  for (let key of keys) if (is_object(value)) value = value[key];
  return value;
};

which works as you'd expect.

As for Atom, it would be possible to make typechecked overloads, but it's probably simpler to make typechecked wrappers, which you can do without modification to the lib.

Hope this helps!

@postspectacular
Copy link
Member

I'm away on a long weekend trip, but already started doing some work on this a while ago and some similar typedefs are already included in @thi.ng/api...

also see: https://twitter.com/thing_umbrella/status/1111204214477410304

@earthlyreason
Copy link
Contributor

No surprise that @postspectacular is already on the case.

FWIW the reason I ended up using a function-based approach (rather than standalone path tuples) was that in practice I could only get the required inference from a call site. This may be alleviated by partial inference, but that is apparently harder than it sounds, as it's been kicked to a "future" item.

@postspectacular
Copy link
Member

postspectacular commented May 30, 2019

That's some impressive type wrangling @gavinpc-mindgrub! Will check this out ASAP and see how this could be integrated... thanks!!!

Here's a completely different (and more basic) approach I've taken in a bunch of projects: pre-declare lookup paths and use these consts every time a value needs to be accessed. It's pretty easy to refactor, but still doesn't give you type info for the values (didn't find this a hindrance so far):

// api.ts
export interface AppState {
    ephemeral: {
        ui: UIState;
    };
    persistent: {
        // changes to this branch will be undoable via History
        // branch also saved to localStorage
        ...
    }
}

export interface UIState {
    mouse: MouseState;
    keys: KeyState;
}

export interface MouseState {
    pos: number[];
    button: number;
}

export interface KeyState {
    keys: Set<string>;
    modifiers: Set<string>;
}
// paths.ts
export const E_BASE = ["ephemeral"];
export const P_BASE = ["persistent"];

export const UI_BASE = [...E_BASE, "ui"];

export const MOUSE_STATE = [...UI_BASE, "mouse"];
export const MOUSE_POS = [...MOUSE_STATE, "pos"];

export const KEY_STATE = [...UI_BASE, "keys"];
export const MODIFIER_KEYS = [...KEY_STATE, "modifiers"];
// state.ts
import { Atom, Cursor, History } from "@thi.ng/atom";
import { equiv } from "@thi.ng/equiv";
import { getIn, setIn } from "@thi.ng/paths";

import * as paths from "./paths";

const state = new Atom({});
const history = new History(new Cursor(state, paths.P_BASE));

// localStorage watch
history.addWatch("localstorage", (_, prev, curr) => {
    if(curr && !equiv(curr, prev)) {
        localStorage.setItem("appstate", JSON.stringify(curr));
    }
});

// views
const mousePos = state.addView<number[]>(
    paths.MOUSE_POS,
    (x) => x ||  [0, 0]
);
const isShiftDown = state.addView<boolean>(
    paths.MODIFIER_KEYS,
    (x) => x.has("Shift")
);

// updaters
const setMouse = (m: MouseState) => state.resetIn(paths.MOUSE_STATE, m);
const setKeys = (k: KeyState) => state.resetIn(paths.KEY_STATE, k);
// etc.

@acarabott
Copy link
Contributor Author

An update on my current approach...
Most of the time I'm doing shallow updates (first layer) so have found these typed wrappers super helpful, as the value types can be inferred:

import { Atom } from "@thi.ng/atom";

export const updateAtom = <T>(atom: Atom<T>, update: Partial<T>) =>
  atom.reset({ ...atom.value, ...update });

export const updateAtomFactory = <T>(atom: Atom<T>) => (update: Partial<T>) =>
  updateAtom(atom, update);

export const swapAtom = <T, P extends keyof T, V = T[P]>(
  atom: Atom<T>,
  path: P,
  swapFn: (value: V) => V,
) => atom.swapIn<V>(path, swapFn);

export const resetAtom = <T, P extends keyof T, V = T[P]>(atom: Atom<T>, path: P, value: V) =>
  atom.resetIn<V>(path, value);

export const addView = <A, P extends keyof A, V>(
  atom: Atom<A>,
  path: P,
  tx: (x: A[P]) => V,
  lazy?: boolean | undefined,
) => atom.addView<V>(path, tx, lazy);

Which allows you to do have the type inferred on calls like

interface IState {
    name: string
}

const db = new Atom<IState>({ name: "Arthur" });

swapAtom(db, "name", "Arturo");

@postspectacular
Copy link
Member

hey @acarabott - thanks for these! I've been slowly gathering more experience with mapped types and I will update the paths package (ASAP) using either the already existing helper types for that purpose or a similar approach... Using such recursive mapped types, we are able to capture the types for several (maybe even all?) levels of the state values.

import {
    Keys, Keys1, Keys2,
    Val1, Val2, Val3
} from "@thi.ng/api";
import { getIn } from "@thi.ng/paths";

export function getInTyped<T, K extends Keys<T>>(
    state: T,
    path: [K]
): Val1<T, K>;
export function getInTyped<T, K extends Keys<T>, K2 extends Keys1<T, K>>(
    state: T,
    path: [K, K2]
): Val2<T, K, K2>;
export function getInTyped<
    T,
    K extends Keys<T>,
    K2 extends Keys1<T, K>,
    K3 extends Keys2<T, K, K2>
>(state: T, path: [K, K2, K3]): Val3<T, K, K2, K3>;
export function getInTyped(state: any, path: string[]) {
    return getIn(state, path);
}

// example

interface State {
    ui: {
        mouse: number[];
        key: string;
    }
}

const state = <State>{};

const mpos = getInTyped(state, ["ui", "mouse", 0]); // number
const key = getInTyped(state, ["ui", "key"]); // string

@postspectacular
Copy link
Member

@acarabott since that above example worked quite nicely, I've just added typed versions of all the functions in that package (see 319f4f8). I think, for now, adding instead of updating them is a better solution here, since else it's going to be a breaking change and there're a lot of use cases and existing code, where I also still need the untyped versions for...

@postspectacular
Copy link
Member

Just for reference, some of that new functionality (e.g. deleteInT()) is using these new mapped types in @thi.ng/api. I.e. deleteInT() now returns a new type with the nested key removed using a version of the mapped Replace{X} types...

In terms of usability, the only issue is that it's not as straightforward anymore to pass a lookup Path variable to any of these typed functions and I can't think of a way to solve this...

postspectacular added a commit that referenced this issue Nov 30, 2019
* feature/paths-refactor:
  chore(examples): fix todolist pkg name, update readmes
  docs(paths): update readme & pkg desc
  refactor(paths): update fn order, update docs
  fix(paths): update fn signatures (remove obsolete)
  feat(paths): #87, add typed versions of all fns, split into sep files
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants