-
-
Notifications
You must be signed in to change notification settings - Fork 151
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
Comments
@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 /** 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 Hope this helps! |
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 |
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. |
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 // 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. |
An update on my current approach...
Which allows you to do have the type inferred on calls like
|
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 |
@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... |
Just for reference, some of that new functionality (e.g. In terms of usability, the only issue is that it's not as straightforward anymore to pass a lookup |
* 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
A common issue I run into with Views, Watches, and Cursors is renaming the property on the underlying Atom, e.g.
Before renaming:
After renaming
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.The text was updated successfully, but these errors were encountered: