microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.59k stars 12.44k forks source link

Typing for object deep paths #12290

Closed wclr closed 7 years ago

wclr commented 7 years ago

Recently introduces keyof operator will work well for typing functions that accept properties directly on target type.

interface Some {
  a: number,
  b: string
}

type oneOfTheSomeKeys = keyof Some // restricts value to "a", "b"

What do you think, is it possible in theory to type deeply nested paths, so that for type:

interface Some {
  a: number,
  b: {
    c: number
    d: {
      e: number
    }
  }
}

so that it would be possible to restrict possible path values to:

["a"]
["b"]
["b", "c"]
["b", "d"]
["b", "d, "e"]

This actual for such methods like ramda's path:

R.path(['a', 'b'], {a: {b: 2}}); //=> 2
R.path(['a', 'b'], {c: {b: 2}}); //=> undefined

?

HerringtonDarkholme commented 7 years ago

You can already do that with manual overloading.

// overload more type parameter arity here
function path<A extends string, B extends string, C extends string, D>(path: [A, B, C], d: {
    [K1 in A]: {[K2 in B]: {[K3 in C]: D}}
}) {
    let ret = d
    for (let k of path) {
        ret = ret[k as string]
    }
}

path(['a', 'b', 'c'], {a: {b: 2}}) // error
path(['a', 'b', 'c'], {a: {b: {c: 2}}})
wclr commented 7 years ago

@HerringtonDarkholme oh that is really really nice, thanks for the example!

aluanhaddad commented 7 years ago

@HerringtonDarkholme very slick

KiaraGrouwstra commented 7 years ago

@HerringtonDarkholme: thank you, that's pretty cool! I generated variants for different path lengths for Ramda, so if you'd like to use it, feel free. :smile: I tried to see if I could make your definition work with arrays as well, but so far I haven't had much luck. Cases I'm hoping will work (or at least one of the two):

path(['a', '0', 'c'], {a: [{c: 2}] })
path(['a', 0, 'c'], {a: [{c: 2}] })

I tried to see if adjusting the definition might help to make this work out.

function path<A extends string, B extends number, C extends string, D>(path: [A, B, C], d: { // <- changed B to number
    [K1 in A]: {[K2 in B]: {[K3 in C]: D}}  // <- `K2 in B` now errors: "Type 'number' is not assignable to type 'string'"
}) {
    // implementation detail
}

I suppose with {[K2 in B]: ...} this is being considered an object using a string-based index, making numerical indices (as used by arrays) fail. Perhaps this is implied by the in notation?

wclr commented 7 years ago

@tycho01

To make this:

path(['a', '0', 'c'], { a: [{ c: 2 }] })
path(['a', 0, 'c'], { a: [{ c: 2 }] })

work, typings should be something like that:

// for level 1 array
function path<A extends string, B extends string | number, C extends string, D>
  (path: [A, B, C],
  d: {[K1 in A]: {[K2 in C]: D}[]}
  ): D

// for object
function path<A extends string, B extends string | number, C extends string, D>
  (path: [A, B, C],
  d: {[K1 in A]: {[K2 in B]: {[K3 in C]: D}}}
  ): D

function path(path: any, d: any): any {
  let ret = d
  for (let k of path) {
    ret = ret[k]
  }
}

The problem that it is not possible to do it withing a single signature for example like:

  d: {[K1 in A]: {[K2 in B]: {[K3 in C]: D}}} | {[K1 in A]: {[K2 in C]: D}[]}

So if to still implement it there would be a need to have multiple combinations:

A: O : O : O 
A: A : O : O 
...
O: A : O : O 
...

You get it. But it is not impossible though.

KiaraGrouwstra commented 7 years ago

Yeah, the exploding number of variants is a tad unfortunate, but for the moment should do if I can try to generate them. I guess technically the [] approach still presents an asymmetry between the objects and array versions, by constraining the arrays to be homogeneous lists, as opposed to say tuples, while the objects do not appear bound that way. That said, this progress is pretty great! I'll try to incorporate your idea for the Ramda function. :D

wclr commented 7 years ago

Yes it seem that is also case with tuples which makes typing issue unsolvable for general case.

KiaraGrouwstra commented 7 years ago

@whitecolor: made a commit for path lengths 1~7 (-> index.d.ts). Lotta code to get one extra test to pass, with still dozens others failing (not to mention the ones silently 'failing' with any types). Worth it! Can't wait to see what it'd look like if it is to handle tuples as well!

wclr commented 7 years ago

@HerringtonDarkholme or somebody

any advice how this can be typed, function that gets two key names and object and checks if first key is string, and second is number:

function checkTypesOfKeys<
  KeyS extends string, KeyN extends string>
  (keyS: KeyS, keyN: KeyN,
  obj: {[K in KeyS]: string} & {[K in KeyN ]: number}): boolean // this doesn't work
{
  return typeof (<any>obj)[keyS] === 'string'
    && typeof (<any>obj)[keyN] === 'number'
}

checkTypesOfKeys('str', 'num', {str: 'stringValue', num: 1}) // error
  Type '{ str: string; num: number; }' is not assignable to type '{ str: string; num: string; }'.
KiaraGrouwstra commented 7 years ago

@whitecolor: what if you make that KeyN into number?

On that path definition I generated for Ramda based on the overloading suggestion given here, I've come to the conclusion this not only brings monstrous typings that are still inadequate (ignoring tuples), but also brings along performance issues, grinding TS to a halt after adding a significant number of overloads. Evidently, just following the original reduce logic is O(n), so I hope they'll consider my proposal to implement that as a solution...

wclr commented 7 years ago

KeyN in my question I assume to correspond to second key name (key name is a string anyway) argument.

I hope they'll consider my proposal to implement that as a solution...

In 10 years maybe =)

HerringtonDarkholme commented 7 years ago

@whitecolor

The problem here is how TypeScript infer type argument. The KeyN and KeyM will be inferred against { str: string; num: number; } so that compiler will infer types compatible both with extends string and keyof typeof obj.

In such condition, KeyN is inferred as str | num. So the error.

One solution is using curry to help compiler infer type argument.

https://www.typescriptlang.org/play/index.html#src=function%20checkTypesOfKeys%3C%0D%0A%20%20KeyS%20extends%20string%2C%20KeyN%20extends%20string%3E%0D%0A%20%20(keyS%3A%20KeyS%2C%20keyN%3A%20KeyN)%3A%20(obj%3A%20%7B%5BK%20in%20KeyS%5D%3A%20string%7D%20%26%20%7B%5BK%20in%20KeyN%20%5D%3A%20number%7D)%20%3D%3E%20boolean%20%2F%2F%20this%20doesn%27t%20work%0D%0A%7B%0D%0A%20%20return%20(obj)%20%3D%3E%20typeof%20(%3Cany%3Eobj)%5BkeyS%5D%20%3D%3D%3D%20%27string%27%0D%0A%20%20%20%20%26%26%20typeof%20(%3Cany%3Eobj)%5BkeyN%5D%20%3D%3D%3D%20%27number%27%0D%0A%7D%0D%0A%0D%0AcheckTypesOfKeys(%27str%27%2C%20%27num%27)(%7Bstr%3A%20%27stringValue%27%2C%20num%3A%201%7D)

wclr commented 7 years ago

@HerringtonDarkholme Thanks for suggestion)

Any advice is it possible to solve more complicated case, to check if target object, contains props of orignal object with the same corresponding types?

function compareTypesOfKeys<
  KeyS extends string, KeyN extends string>
  (original: { [K in (Keys & KeyN)]: any}):
  (target: {[K in KeyS]: string} & {[K in KeyN]: number}) => boolean // this doesn't work
{
  return (target: any): boolean => {
    let isTheSameTypesOfKeys: boolean = true
    Object.keys(original).forEach((keyInOriginal) => {
      if (typeof target[keyInOriginal] !== typeof original[keyInOriginal]) {
        isTheSameTypesOfKeys = false
      }
    })
    return isTheSameTypesOfKeys
  }
}

compareTypesOfKeys({
  str: 'str',
  num: 1
})({ str: 'stringValue', num: 1 })
KiaraGrouwstra commented 7 years ago

@mhegazy: I wouldn't consider this properly resolved; the known workaround of mass overloading gives performance issues to the extent of no longer being able to compile. This is in need for a better solution than is possible today.

RafaelSalguero commented 7 years ago

I have another work around, tested on typescript 2.3.4: Still can't type array based paths such as the one of ramda Given the function:

/**
 * Create a deep path builder for a given type
 */
export function path<T>() {
    /**Returns a function that gets the next path builder */
    function subpath<T, TKey extends keyof T>(parent: string[], key: TKey): PathResult<T[TKey]> {
        const newPath = [...parent, key];
        const x = (<TSubKey extends keyof T[TKey]>(subkey: TSubKey) => subpath<T[TKey], TSubKey>(newPath, subkey)) as PathResult<T[TKey]>;
        x.path = newPath;
        return x;
    }

    return <TKey extends keyof T>(key: TKey) => subpath<T, TKey>([], key);
}

Use:

interface MyDeepType {
    person: {
        names: {
            lastnames: {
                first: string,
                second: string;
            }
            firstname: string;
        }
        age: number;
    }
    other: string;
}

//All path parts are checked and intellisense enabled:
const x = path<MyDeepType>()("person")("names")("lastnames")("second");
const myPath: string[] = x.path;
KiaraGrouwstra commented 7 years ago

So close, yet so far:

export type Inc = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256];

export type TupleHasIndex<Arr extends any[], I extends number> = ({[K in keyof Arr]: '1' } & Array<'0'>)[I];
// ^ #15768, TS2536 `X cannot be used to index Y` on generic. still works though.

export type PathFn<T, R extends Array<string | number>, I extends number = 0> =
    { 1: PathFn<T[R[I]], R, Inc[I]>, 0: T }[TupleHasIndex<R, I>];
type PathTest = PathFn<{ a: { b: ['c', { d: 'e' }] } }, ['a', 'b', 1, 'd']>;
// "e". yay!

export declare function path<T, R extends Array<string|number>>(obj: T, path: R): PathFn<T, R>;
const pathTest = path({ a: { b: ['c', { d: 'e' }] } }, ['a', 'b', 1, 'd'])
// { a: { b: (string | { d: string; })[]; }; }. weird...

Edit: filed what I believe to be a minimum repro of this issue at #17086.

KiaraGrouwstra commented 7 years ago

cc @ikatyang I got a working function-based PoC for path on master now:

type Inc = { [k: string]: string; 0:'1', 1:'2', 2:'3', 3:'4', 4:'5', 5:'6', 6:'7', 7:'8', 8:'9' };
type StringToNumber = { [k: string]: number; 0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8};

type TupleHasIndex<Arr extends any[], I extends string> = ({[K in keyof Arr]: '1' } & { [k: string]: '0' })[I];

type PathFn<T extends { [k: string]: any }, R extends Array<string>, I extends string = '0'> =
    { 1: PathFn<T[R[StringToNumber[I]]], R, Inc[I]>, 0: T }[TupleHasIndex<R, I>];
type PathTest = PathFn<{ a: { b: ['c', { d: 'e' }] } }, ['a', 'b', '1', 'd']>;
// "e"

declare function path<T extends { [k: string]: any }, R extends Array<string>>(obj: T, path: R): PathFn<T, R>;
let obj: { a: { b: ['c', { d: 'e' }] } };
let keys: ['a', 'b', '1', 'd'];
const pathTest = path(obj, keys);
// "e"

So the numbers are passed as keys there and the outer structure here must have a string index (non-array), but yay, progress!

ikatyang commented 7 years ago

@tycho01

Good news: it also works with v2.5.1. 🎉

Bad news: needs to provide exact type (no widen) and contextual inference seems not working here, but yeah, progress! 👍

declare const obj: { a: { b: ['c', { d: 'e' }] } };
declare const keys: ['a', 'b', '1', 'd'];

path(obj, keys) //=> 👍
path(obj, ['a', 'b', '1', 'd']) //=> 💥 
path({ a: { b: ['c', { d: 'e' }] } }, keys) //=> 💥 
path({ a: { b: ['c', { d: 'e' }] } }, ['a', 'b', '1', 'd']) //=> 💥 
KiaraGrouwstra commented 7 years ago

@ikatyang: Yeah. At least the widening issue I'm hoping to resolve with #17785.

Goobles commented 6 years ago

I might be a little late on the issue here but, since v2.8 released, with the infer keyword and conditional types I was able to cook up this:

type KeyOf<T> = keyof T;

interface DeepKeyOfArray<T> extends Array<string> {
  ['0']?: KeyOf<T>;
  ['1']?: this extends {
    ['0']?: infer K0
  } ? K0 extends KeyOf<T> ? KeyOf<T[K0]> : never : never;
  ['2']?: this extends {
    ['0']?: infer K0;
    ['1']?: infer K1;
  } ? K0 extends KeyOf<T> ? K1 extends KeyOf<T[K0]> ? KeyOf<T[K0][K1]> : never : never : never;
  ['3']?: this extends {
    ['0']?: infer K0;
    ['1']?: infer K1;
    ['2']?: infer K2;
  } ? K0 extends KeyOf<T> ? K1 extends KeyOf<T[K0]> ? K2 extends KeyOf<T[K0][K1]> ? KeyOf<T[K0][K1][K2]> : never : never : never : never;
  ['4']?: this extends {
    ['0']?: infer K0;
    ['1']?: infer K1;
    ['2']?: infer K2;
    ['3']?: infer K3;
  } ? K0 extends KeyOf<T> ? K1 extends KeyOf<T[K0]> ? K2 extends KeyOf<T[K0][K1]> ? K3 extends KeyOf<T[K0][K1][K2]> ? KeyOf<T[K0][K1][K2][K3]> : never : never : never : never : never;
  ['5']?: this extends {
    ['0']?: infer K0;
    ['1']?: infer K1;
    ['2']?: infer K2;
    ['3']?: infer K3;
    ['4']?: infer K4;
  } ? K0 extends KeyOf<T> ? K1 extends KeyOf<T[K0]> ? K2 extends KeyOf<T[K0][K1]> ? K3 extends KeyOf<T[K0][K1][K2]> ? K4 extends KeyOf<T[K0][K1][K2][K3]> ? KeyOf<T[K0][K1][K2][K3][K4]> : never : never : never : never : never : never;
  ['6']?: this extends {
    ['0']?: infer K0;
    ['1']?: infer K1;
    ['2']?: infer K2;
    ['3']?: infer K3;
    ['4']?: infer K4;
    ['5']?: infer K5;
  } ? K0 extends KeyOf<T> ? K1 extends KeyOf<T[K0]> ? K2 extends KeyOf<T[K0][K1]> ? K3 extends KeyOf<T[K0][K1][K2]> ? K4 extends KeyOf<T[K0][K1][K2][K3]> ? K5 extends KeyOf<T[K0][K1][K2][K3][K4]> ? KeyOf<T[K0][K1][K2][K3][K4][K5]> : never : never : never : never : never : never : never;
}

type ArrayHasIndex<MinLenght extends string> = { [K in MinLenght]: any; };

export type DeepTypeOfArray<T, L extends DeepKeyOfArray<T>> =
  L extends ArrayHasIndex<'7'> ?
  any :
  L extends ArrayHasIndex<'6'> ?
  T[L['0']][L['1']][L['2']][L['3']][L['4']][L['5']][L['6']] :
  L extends ArrayHasIndex<'5'> ?
  T[L['0']][L['1']][L['2']][L['3']][L['4']][L['5']] :
  L extends ArrayHasIndex<'4'> ?
  T[L['0']][L['1']][L['2']][L['3']][L['4']] :
  L extends ArrayHasIndex<'3'> ?
  T[L['0']][L['1']][L['2']][L['3']] :
  L extends ArrayHasIndex<'2'> ?
  T[L['0']][L['1']][L['2']] :
  L extends ArrayHasIndex<'1'> ?
  T[L['0']][L['1']] :
  L extends ArrayHasIndex<'0'> ?
  T[L['0']] :
  T;

export type DeepKeyOf<T> = DeepKeyOfArray<T> | KeyOf<T>;

export type DeepTypeOf<T, L extends DeepKeyOf<T>> =
  L extends DeepKeyOfArray<T> ?
  DeepTypeOfArray<T, L> :
  L extends KeyOf<T> ?
  T[L] :
  never;

This is really verbose but allows the input array to be checked with keyof recursively.

declare function path<T, L extends DeepKeyOf<T>>(object: T, params?: L): DeepTypeOf<T, L>;

const obj = {
  v: { w: { x: { y: { z: { a: { b: { c: 2 } } } } } } }
};
const output: number = path(obj, ['v', 'w', 'x']); // 💥
const output2: object = path(obj, ['v', 'w', 'x']); // ✔️
const output4: { c: string } = path(obj, ['v', 'w', 'x', 'y', 'z', 'a', 'b']); // 💥
const output3: { c: number } = path(obj, ['v', 'w', 'x', 'y', 'z', 'a', 'b']); // ✔️
const output5: { wrong: 'type' } = path(obj, ['v', 'w', 'x', 'y', 'z', 'a', 'b', 'c']); // ✔️ since after 7 levels there is no typechecking

path(obj, '!'); // 💥
path(obj, ['!']); // 💥
path(obj, ['v', '!']); // 💥
path(obj, ['v', 'w', '!']); // 💥
path(obj, ['v', 'w', 'x', '!']); // 💥
path(obj, ['v', 'w', 'x', 'y', '!']); // 💥
path(obj, ['v', 'w', 'x', 'y', 'z', '!']); // 💥
path(obj, ['v', 'w', 'x', 'y', 'z', 'a', '!']); // 💥
path(obj, ['v', 'w', 'x', 'y', 'z', 'a', 'b', '!']); // ✔️ since after 7 levels there is no typechecking
path(obj, 'v'); // ✔️
path(obj, ['v']); // ✔️
path(obj, ['v', 'w']); // ✔️
path(obj, ['v', 'w', 'x']); // ✔️
path(obj, ['v', 'w', 'x', 'y']); // ✔️
path(obj, ['v', 'w', 'x', 'y', 'z']); // ✔️
path(obj, ['v', 'w', 'x', 'y', 'z', 'a']); // ✔️
path(obj, ['v', 'w', 'x', 'y', 'z', 'a', 'b']); // ✔️
path(obj, ['v', 'w', 'x', 'y', 'z', 'a', 'b', 'c']); // ✔️
karol-majewski commented 6 years ago

@Goobles Impressive work! This is clearly an advancement. I have a question though: do you get compilation errors inside DeepTypeOfArray? It seems TypeScript doesn't like L['0'] used to index T.

Refer to TypeScript playground to see the errors.

I haven't found a way to restrict inferred types to their respective branches, too. Please consider a following example:

const obj = {
  v: {
    w: { x: { y: { z: { a: { b: { c: 2 } } } } } },
    ouch: true, // Additional property at level 2
  }
};

A property called ouch was introduced next to obj.v.w. It's now possible to take valid path used to retrieve output3, swap the property names wouch and continue without an error, even though the path is no longer correct.

path(obj, ['v', 'ouch', 'x', 'y', 'z', 'a', 'b']) === output3; // No error (wrong)

Is it even possible, in your opinion, to cover such cases?

KiaraGrouwstra commented 6 years ago

@karol-majewski looks like L['0'] yields T | undefined as we haven't proven (to TS) that T contains 0. Now, the easiest way to prove this would seem to be to check not just for the highest index, but for all of them, meaning we'd need something more complex than just ArrayHasIndex. I guess that could be done with a switch-like extends chain as you have been doing, or maybe using recursion as well, (though I liked to try and use that to handle the other logic here as well).

Goobles commented 6 years ago

@karol-majewski apparently L needs to be a type that has all the keys, not just the last one

I fixed the playground example to not have that error:

also, the w -> ouch problem is because the Boolean and Number type somehow has a property named x:any, in the compiler. I am not sure why.

If you try

path(obj, ['v','ouch','anythingElse'])

it would give a compilation error (I also added this as an example in the playground)

Nevermind it's because w has the property x and DeepKeyOf takes the union of w and ouch

luchillo17 commented 6 years ago

Would love to see something like this for Lodash's get for the path.

KiaraGrouwstra commented 6 years ago

@luchillo17 fwiw, TS can't handle notations like _.get(object, 'a[0].b.c') -- can't split string literal types on .. Nor do other operations on type literals, see e.g. #15645 for numbers.

luchillo17 commented 6 years ago

I know, if it could someone would have changed the typings by now, i'm just saying this thread relates a lot to that feature in lodash.

KiaraGrouwstra commented 6 years ago

@luchillo17 Yeah :), all but that bit should apply there too.

ccorcos commented 6 years ago

@Goobles @karol-majewski are you not getting this type error?

Type 'L["0"]' cannot be used to index type 'T'.
(type parameter) T in type DeepTypeOfArray<T, L extends DeepKeyOfArray<T>>

playground

karol-majewski commented 6 years ago

@ccorcos Not in the one fixed by @Goobles. ;-)

ccorcos commented 6 years ago

I see. Thanks @karol-majewski

Here's the result of me playing around with this.

interface Record {
    a: number
    b?: number
    c: Array<string>
    d?: Array<string>
    e: {
        a: number
        b?: number
    },
    f?: {
        a: number
        b?: number
    },
}

declare const record: Record

path(record, ["a"]) // ✔️
path(record, ["b"]) // ✔️
path(record, ["c"]) // ✔️
path(record, ["c", 0]) // 💥
path(record, ["e", "a"]) // ✔️
path(record, ["f", "a"]) // ✔️ except the type is any 💥

I'm having trouble with objects that have optional types in their path... Its also not possible to index into an array...

micimize commented 6 years ago

@ccorcos You can probably handle optional types by applying DeepFull in `path:

declare function path<T, L extends DeepKeyOf<DeepFull<T>>>(object: T, params?: L): DeepTypeOf<DeepFull<T>, L>;

In case it's of interest to anyone, a while ago I used @Goobles's solution to build some extensible lens building utilities using proxies. Unfortunately, upgrading to 2.9 makes type checking it unusably slow, and I'm guessing that's the case for anything using this solution of sufficient size - might have just been my particular setup though.

Morglod commented 6 years ago

With ts-pathof

import { pathOf } from 'ts-pathof';

const obj = {a: {b: 2}};
R.path(pathOf(obj, 'a', 'b'), obj);
agalazis commented 6 years ago

@Morglod having a look at the source it should work for most of the cases but could break on some weirdly deep cases

Morglod commented 6 years ago

@agalazis just updated ts-pathof

Now you there is hasPath:

import { hasPath } from 'ts-pathof';

const c = { z: { y: { bb: 123 }}};
const path = hasPath(c, [ 'z', 'y', 'bb' ]);
path -> [ 'z', 'y', 'bb' ]

const path2 = hasPath(c, [ 'z', 'y', 'gg' ]); // no error
path2 -> value is false, type is never

So you can:

import { hasPath} from 'ts-pathof';

const obj = {a: {b: 2}};
R.path(hasPath(obj, ['a', 'b']), obj);
ifiokjr commented 5 years ago

For some reason @Goobles when I copy your solution to work on it in my editor I see the following TypeScript error.

['X'] is referenced directly or indirectly in its own type annotation

Not sure what's causing the issue. I'm using TS 3.1.3

image

TheAifam5 commented 5 years ago

@ifiokjr try replace ?: this everywhere... exactly only "this" -> "DeepKeyOfArray" ... maybe works :D

irond13 commented 5 years ago

@Morglod @agalazis I've implemented a 'recursive' version of PathOf:

interface NextInt {
  0: 1,
  1: 2,
  2: 3,
  3: 4,
  4: 5
  [rest: number]: number
}

type PathType<T, P extends string[], Index extends (keyof P & number) = 0> = {
  [K in (keyof P & number & Index)]: P[K] extends undefined
                                        ? T
                                        : P[K] extends keyof T
                                            ? NextInt[K] extends (keyof P & number)
                                              ? PathType<T[P[K]], P, Extract<NextInt[K], (keyof P & number)>>
                                              : T[P[K]]
                                            : never
}[Index]; 

The only non-dynamic part is NextInt which, as above, only supports path lengths of up to 5. The mapped type business was required to get around the cyclical reference restrictions.

Usage:

const o = {x: { y: 10 }};

type x = PathType<typeof o, []>; // => typeof o
type xy = PathType<typeof o, ['x', 'y']>; // => number
type xyz = PathType<typeof o, ['x', 'y', 'z']>; // => never
Morglod commented 5 years ago

@irond13 Thats the main problem with all recursive implementations - restrictions in TS for cyclical references 😞

irond13 commented 5 years ago

@irond13 Thats the main problem with all recursive implementations - restrictions in TS for cyclical references 😞

In this case, it's more of an annoyance than a blocker.

FYI, to support longer path lengths, the NextInt interface just needs to be expanded. So, with the following, path lengths of up to 10 are supported:

interface NextInt {
  0: 1,
  1: 2,
  2: 3,
  3: 4,
  4: 5,
  5: 6,
  6: 7,
  7: 8,
  8: 9,
  9: 10
  [rest: number]: number
}
KiaraGrouwstra commented 5 years ago

I wonder how long it'll take us to pull off lenses/traversals/prisms. :D

ccorcos commented 5 years ago

Just playing around with some edge cases:


type X = {
    a: 1, 
    b: { c: 2 } | { d: 3 }
    c?: Array<{ d: string }>
    d?: {
        e?: {
            f: 1
        }
    }
}

// Doesn't work with union types.
type A = PathType<X, ["b", "c"]>

// Doesn't work with Arrays
type B = PathType<X, ["c", "d"]>

// Doesn't work with optional types with strictNullChecks
type C = PathType<X, ["d", "e"]>

link

diegohaz commented 5 years ago

I tweaked a little @Goobles' code so as to make it work with the latest versions of TypeScript:

interface PathArray<T, L> extends Array<string | number> {
  ["0"]?: keyof T;
  ["1"]?: L extends {
    ["0"]: infer K0;
  }
    ? K0 extends keyof T
      ? keyof T[K0]
      : never
    : never;
  ["2"]?: L extends {
    ["0"]: infer K0;
    ["1"]: infer K1;
  }
    ? K0 extends keyof T
      ? K1 extends keyof T[K0]
        ? keyof T[K0][K1]
        : never
      : never
    : never;
  ["3"]?: L extends {
    ["0"]: infer K0;
    ["1"]: infer K1;
    ["2"]: infer K2;
  }
    ? K0 extends keyof T
      ? K1 extends keyof T[K0]
        ? K2 extends keyof T[K0][K1]
          ? keyof T[K0][K1][K2]
          : never
        : never
      : never
    : never;
  ["4"]?: L extends {
    ["0"]: infer K0;
    ["1"]: infer K1;
    ["2"]: infer K2;
    ["3"]: infer K3;
  }
    ? K0 extends keyof T
      ? K1 extends keyof T[K0]
        ? K2 extends keyof T[K0][K1]
          ? K3 extends keyof T[K0][K1][K2]
            ? keyof T[K0][K1][K2][K3]
            : never
          : never
        : never
      : never
    : never;
  ["5"]?: L extends {
    ["0"]: infer K0;
    ["1"]: infer K1;
    ["2"]: infer K2;
    ["3"]: infer K3;
    ["4"]: infer K4;
  }
    ? K0 extends keyof T
      ? K1 extends keyof T[K0]
        ? K2 extends keyof T[K0][K1]
          ? K3 extends keyof T[K0][K1][K2]
            ? K4 extends keyof T[K0][K1][K2][K3]
              ? keyof T[K0][K1][K2][K3][K4]
              : never
            : never
          : never
        : never
      : never
    : never;
  ["6"]?: L extends {
    ["0"]: infer K0;
    ["1"]: infer K1;
    ["2"]: infer K2;
    ["3"]: infer K3;
    ["4"]: infer K4;
    ["5"]: infer K5;
  }
    ? K0 extends keyof T
      ? K1 extends keyof T[K0]
        ? K2 extends keyof T[K0][K1]
          ? K3 extends keyof T[K0][K1][K2]
            ? K4 extends keyof T[K0][K1][K2][K3]
              ? K5 extends keyof T[K0][K1][K2][K3][K4]
                ? keyof T[K0][K1][K2][K3][K4][K5]
                : never
              : never
            : never
          : never
        : never
      : never
    : never;
}

type ArrayHasIndex<MinLenght extends number> = { [K in MinLenght]: any };

export type PathArrayValue<
  T,
  L extends PathArray<T, L>
> = L extends ArrayHasIndex<0 | 1 | 2 | 3 | 4 | 5 | 6 | 7>
  ? any
  : L extends ArrayHasIndex<0 | 1 | 2 | 3 | 4 | 5 | 6>
  ? T[L[0]][L[1]][L[2]][L[3]][L[4]][L[5]][L[6]]
  : L extends ArrayHasIndex<0 | 1 | 2 | 3 | 4 | 5>
  ? T[L[0]][L[1]][L[2]][L[3]][L[4]][L[5]]
  : L extends ArrayHasIndex<0 | 1 | 2 | 3 | 4>
  ? T[L[0]][L[1]][L[2]][L[3]][L[4]]
  : L extends ArrayHasIndex<0 | 1 | 2 | 3>
  ? T[L[0]][L[1]][L[2]][L[3]]
  : L extends ArrayHasIndex<0 | 1 | 2>
  ? T[L[0]][L[1]][L[2]]
  : L extends ArrayHasIndex<0 | 1>
  ? T[L[0]][L[1]]
  : L extends ArrayHasIndex<0>
  ? T[L[0]]
  : never;

export type Path<T, L> = PathArray<T, L> | keyof T;

export type PathValue<T, L extends Path<T, L>> = L extends PathArray<T, L>
  ? PathArrayValue<T, L>
  : L extends keyof T
  ? T[L]
  : any;

declare function path<T, L extends Path<T, L>>(
  object: T,
  params: L
): PathValue<T, L>;

const obj = {
  v: {
    w: { x: { y: { z: { a: { b: { c: 2 } } } } } },
    ouch: true,
    foo: [{ bar: 2 }, { bar: 3 }]
  }
};

const output: number = path(obj, ["v", "w", "x"]); // 💥
const output2: object = path(obj, ["v", "w", "x"]); // ✔️
const output4: { c: string } = path(obj, ["v", "w", "x", "y", "z", "a", "b"]); // 💥
const output3: { c: number } = path(obj, ["v", "w", "x", "y", "z", "a", "b"]); // ✔️
const output5: "wrong" = path(obj, ["v", "w", "x", "y", "z", "a", "b", "c"]); // ✔️ since after 7 levels there is no typechecking

const x = path(obj, ["v", "ouch", "x"]); // 💥
const y = path(obj, ["v", "ouch", "y"]); // 💥
const z = path(obj, ["v", "ouch", "somethingCompletelyDifferent"]); // 💥

path(obj, "!"); // 💥
path(obj, ["!"]); // 💥
path(obj, ["v", "!"]); // 💥
path(obj, ["v", "w", "!"]); // 💥
path(obj, ["v", "w", "x", "!"]); // 💥
path(obj, ["v", "w", "x", "y", "!"]); // 💥
path(obj, ["v", "w", "x", "y", "z", "!"]); // 💥
path(obj, ["v", "w", "x", "y", "z", "a", "!"]); // 💥
path(obj, ["v", "w", "x", "y", "z", "a", "b", "!"]); // ✔️ since after 7 levels there is no typechecking
path(obj, "v"); // ✔️
path(obj, ["v"]); // ✔️
path(obj, ["v", "w"]); // ✔️
path(obj, ["v", "w", "x"]); // ✔️
path(obj, ["v", "w", "x", "y"]); // ✔️
path(obj, ["v", "w", "x", "y", "z"]); // ✔️
path(obj, ["v", "w", "x", "y", "z", "a"]); // ✔️
path(obj, ["v", "w", "x", "y", "z", "a", "b"]); // ✔️
path(obj, ["v", "w", "x", "y", "z", "a", "b", "c"]); // ✔️
Goobles commented 5 years ago

Just playing around with some edge cases:

type X = {
  a: 1, 
  b: { c: 2 } | { d: 3 }
  c?: Array<{ d: string }>
  d?: {
      e?: {
          f: 1
      }
  }
}

// Doesn't work with union types.
type A = PathType<X, ["b", "c"]>

// Doesn't work with Arrays
type B = PathType<X, ["c", "d"]>

// Doesn't work with optional types with strictNullChecks
type C = PathType<X, ["d", "e"]>

link

This does work with arrays though, you have to specify the array index in the property traversal array (oh and change the constraints for P to P extends (string|number)[] so it allows number as well)

type B = PathType<X, ["c", 0, "d"]>
agalazis commented 5 years ago

Hello all here is my proposal, something along the lines of pathof as new functionality or being able to spread keyof recursively would be nice to have: https://github.com/Microsoft/TypeScript/issues/20423

hrsh7th commented 5 years ago

I've try solve this typings. I used this recursive type definition method. (https://github.com/Microsoft/TypeScript/issues/14833)

I hope that will help someone.

type Head<U> = U extends [any, ...any[]]
  ? ((...args: U) => any) extends (head: infer H, ...args: any) => any
    ? H
    : never
  : never;

type Tail<U> = U extends [any, any, ...any[]]
  ? ((...args: U) => any) extends (head: any, ...args: infer T) => any
    ? T
    : never
  : never;

type TraversePath<State extends any, T extends any[]> = Head<T> extends keyof State
  ? {
      0: State[Head<T>];
      1: TraversePath<State[Head<T>], Tail<T>>;
    }[Tail<T> extends never ? 0 : 1]
  : never;

export function get<State extends any, Paths extends (string | number)[]>(state: State, ...paths: Paths): TraversePath<State, Paths> {
  const [head, ...tail] = paths;

  if (!state.hasOwnProperty(head)) {
    throw new Error(`state has not ${head}`);
  }

  if (tail.length) {
    return get(state[head], ...tail);
  }

  return state[head];
}
const obj = {
  v: {
    w: { x: { y: { z: { a: { b: { c: 2 } } } } } },
    ouch: true,
    foo: [{ bar: 2 }, { bar: 3 }]
  }
};

const a: typeof obj['v']['w']['x'] = get(obj, 'v', 'w', 'x'); // ✔
const b: typeof obj['v']['ouch'] = get(obj, 'v', 'w', 'x'); // 💥
evilj0e commented 5 years ago

@nanot1m and I wrote small, but perfectly working solution. I hope it will help to you too.

First of all, let's talk about function getIn takes 3 arguments: searchable – object, path – array of strings/variables, default value. It tries to get value of the target property in searchable object, if it fails returns default value.

That typings of getIn tries to compute type of the target property when it exists. If it was an optional field, it makes it required. Returns union of that type and type of default value, 'cos in some cases they are different.

It works perfectly for me, but the realisation has some limitations. Here they are:

For example, [] – has type never[] or {} – has type {}. And I hope you expect something else.

Solution 1. Definition of variable with certain type:

const defaultValue: string[] = [];
const a = { b: ['test'] };
const stringValues = getIn(a, ['b'], defaultValue);

Solution 2 (Unpreferable). Don't pass default value:

const a = { b: ['test'] };
const stringValues = getIn(a, ['b'], defaultValue) || [];
const path = getPath('awesome'); // string[]
const data = getIn(a, path as [string], 'default value'); // string[] -> [string]
const a = { b: { c: 12 } };
const pathPart = 'c' as 'c';
const data = getIn(a, ['a, 'b', pathPart]);

Solution:

type Diff<T, U> = T extends U ? never : T;

type FilterProps<O, P, D> = P extends keyof Diff<O, number | string | null | undefined> ? (Required<Diff<O, number | string | null | undefined>>[P] | D) : D;

declare function getIn<O extends {}, P1 extends string,                                                                             D = null>(obj: O, path: [P1],                 dafaultValue?: D): FilterProps<O, P1, D>;
declare function getIn<O extends {}, P1 extends string, P2 extends string,                                                          D = null>(obj: O, path: [P1, P2],             defaultValue?: D): FilterProps<FilterProps<O, P1, D>, P2, D>;
declare function getIn<O extends {}, P1 extends string, P2 extends string, P3 extends string,                                       D = null>(obj: O, path: [P1, P2, P3],         defaultValue?: D): FilterProps<FilterProps<FilterProps<O, P1, D>, P2, D>, P3, D>;
declare function getIn<O extends {}, P1 extends string, P2 extends string, P3 extends string, P4 extends string,                    D = null>(obj: O, path: [P1, P2, P3, P4],     defaultValue?: D): FilterProps<FilterProps<FilterProps<FilterProps<O, P1, D>, P2, D>, P3, D>, P4, D>;
declare function getIn<O extends {}, P1 extends string, P2 extends string, P3 extends string, P4 extends string, P5 extends string, D = null>(obj: O, path: [P1, P2, P3, P4, P5], defaultValue?: D): FilterProps<FilterProps<FilterProps<FilterProps<FilterProps<O, P1, D>, P2, D>, P3, D>, P4, D>, P5, D>;

declare var k: { a: { a: { user: { login: string; } } } };

var a = getIn(k, ['a', 'a', 'user', 'login']);

var b = getIn({ a: { b: 1 } }, ['a', 'b'], '42')

var c = getIn({ a: { b: { c: 2 } } }, ['a', 'b', 'c'], '42')
ccorcos commented 5 years ago

I adapted @irond13's PathType to handle strictNullChecks and optional types!


interface NextInt {
    0: 1
    1: 2
    2: 3
    3: 4
    4: 5
    [rest: number]: number
}

// prettier-ignore
type PathType<Obj, Path extends Array<string | number>, Index extends number = 0> = {
    // Need to use this object indexing pattern to avoid circular reference error.
    [Key in Index]: Path[Key] extends undefined
        // Return Obj when we reach the end of the Path.
        ? Obj
        // Check if the Key is in the Obj.
        : Path[Key] extends keyof Obj
            // If the Value does not contain null.
            // `T & {}` is a trick to remove undefined from a union type.
            ? Obj[Path[Key]] extends Obj[Path[Key]] & {}
                ? PathType<
                        Obj[Path[Key]],
                        Path,
                        Extract<NextInt[Key], number>
                    >
                // Remove the undefined from the Value, and add it to the union after.
                : undefined | PathType<
                        Obj[Path[Key]] & {},
                        Path,
                        Extract<NextInt[Key], number>
                    >
            : never
}[Index]

type Test = {
    a: 1
    b: { c: 2 } | { d: 3 }
    c?: Array<{ d: string }>
    d?: {
        e?: {
            f: 1
        }
    }
    g: {
        h: 10
    }
}

type Assert<T, V extends T> = V

type _a = PathType<Test, []>
type a = Assert<_a, Test>

type _b = PathType<Test, ["b"]>
type b = Assert<_b, Test["b"]>

type _c0 = PathType<Test, ["c", 0]>
type c0 = Assert<_c0, { d: string } | undefined>

type _c0d = PathType<Test, ["c", 0, "d"]>
type c0d = Assert<_c0d, string | undefined>

type _de = PathType<Test, ["d", "e"]>
type de = Assert<_de, { f: 1 } | undefined>

type _def = PathType<Test, ["d", "e", "f"]>
type def = Assert<_def, 1 | undefined>

type _g = PathType<Test, ["g"]>
type g = Assert<_g, {h: 10}>

type _gh = PathType<Test, ["g", "h"]>
type gh = Assert<_gh, 10>

type _ghz = PathType<Test, ["g", "h", "z"]>
type ghz = Assert<_ghz, never>

playground

ccorcos commented 5 years ago

Sadly, I just realized this isn't the type that I need! 😭

I want a type that enforces a valid path.

function get<O, P extends PathOf<O>>(o: O, p: P): PathType<O, P> {}

Otherwise I get Type instantiation is excessively deep and possibly infinite.

ccorcos commented 5 years ago

Example of this error "Type instantiation is excessively deep and possibly infinite."

function getPath<O, P extends Array<number | string>>(
    o: O,
    p: P
): PathType<O, P> {
    return {} as any
}
lostpebble commented 5 years ago

This is rather frustrating. I really thought there would be a way to do this, but seems like Typescript isn't ready yet.

I've been trying out your examples @ccorcos , the one earlier seemed to work quite well until I discovered that the inferred type for the deep key value is not being used properly in lower levels:

interface DeepKeyOfArray<O> extends Array<string | number> {
  ["0"]: TKeyOf<O>;
  ["1"]?: this extends {
      ["0"]: infer K0;
    }
    ? K0 extends TKeyOf<O> ? TKeyOf<O[K0]> : never : never;
  ["2"]?: this extends {
      ["0"]: infer K0;
      ["1"]: infer K1;
    }
    ? K0 extends TKeyOf<O>
      ? (K1 extends TKeyOf<O[K0]> ? TKeyOf<O[K0][K1]> : never)
      : never
    : never;
  ["3"]?: this extends {
      ["0"]: infer K0;
      ["1"]: infer K1;
      ["2"]: infer K2;
    }
    ? K0 extends TKeyOf<O>
      ? K1 extends TKeyOf<O[K0]>
        ? K2 extends TKeyOf<O[K0][K1]>
          ? TKeyOf<O[K0][K1][K2]> : never : never : never : never;
}

interface IObj {
  count: boolean;
  tagsFirst: string[];
  deep: {
    color: string;
    tags: string[];
    deeper: {
      egg: boolean;
      more: {
        other: number;
      };
    };
  };
}

const path: DeepKeyOfArray<IObj> = ["count", "deeper"];

That path at the bottom there doesn't throw any errors when its clear to see that count is as deep as it goes here, but it continues to allow any keys that are at the second level.

Path referencing in JSON and JavaScript programming is quite a common use case... Would be really nice if we could have an easier way to deal with this.