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

Typing for object deep paths #12290

Closed
wclr opened this issue Nov 16, 2016 · 63 comments
Closed

Typing for object deep paths #12290

wclr opened this issue Nov 16, 2016 · 63 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@wclr
Copy link

wclr commented Nov 16, 2016

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
Copy link
Contributor

HerringtonDarkholme commented Nov 16, 2016

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
Copy link
Author

wclr commented Nov 16, 2016

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

@mhegazy mhegazy added the Question An issue which isn't directly actionable in code label Nov 16, 2016
@aluanhaddad
Copy link
Contributor

@HerringtonDarkholme very slick

@KiaraGrouwstra
Copy link
Contributor

KiaraGrouwstra commented Dec 11, 2016

@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. 😄
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
Copy link
Author

wclr commented Dec 11, 2016

@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
Copy link
Contributor

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
Copy link
Author

wclr commented Dec 11, 2016

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

@KiaraGrouwstra
Copy link
Contributor

@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
Copy link
Author

wclr commented Mar 24, 2017

@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
Copy link
Contributor

@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
Copy link
Author

wclr commented Mar 24, 2017

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
Copy link
Contributor

@wclr
Copy link
Author

wclr commented Mar 24, 2017

@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 })

@mhegazy mhegazy closed this as completed Apr 24, 2017
@KiaraGrouwstra
Copy link
Contributor

@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
Copy link

RafaelSalguero commented Jun 3, 2017

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
Copy link
Contributor

KiaraGrouwstra commented Jun 13, 2017

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.

@ccorcos
Copy link

ccorcos commented Apr 11, 2019

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
Copy link

ccorcos commented Apr 11, 2019

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
Copy link

ccorcos commented Apr 11, 2019

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
Copy link

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.

@andriy-kudrya
Copy link

@ccorcos I've found a workaround to that error although the function signature becomes artificially more complicated

function getPath<O, P extends ReadonlyArray<number | string>>(
	o: O,
	p: P
): () => PathType<O, P> {
	return {} as any
}

also, I've replaced Array<number | string> with ReadonlyArray<number | string> for the sake of type inference

example

@ScreamZ
Copy link

ScreamZ commented Jan 5, 2020

So any way typescript support Dot Notation that's also problematic with things like MongoDB

@hsource
Copy link

hsource commented Apr 23, 2020

While I liked the above functions, I thought the logic is a bit hard to follow. Here's a more verbose, but easy-to-read version that fully typechecks paths of <7 length:

export type PathOfLength1<T, K1 extends keyof T> = [K1];
export type PathOfLength2<T, K1 extends keyof T, K2 extends keyof T[K1]> = [
  K1,
  K2,
];
export type PathOfLength3<
  T,
  K1 extends keyof T,
  K2 extends keyof T[K1],
  K3 extends keyof T[K1][K2]
> = [K1, K2, K3];
export type PathOfLength4<
  T,
  K1 extends keyof T,
  K2 extends keyof T[K1],
  K3 extends keyof T[K1][K2],
  K4 extends keyof T[K1][K2][K3]
> = [K1, K2, K3, K4];
export type PathOfLength5<
  T,
  K1 extends keyof T,
  K2 extends keyof T[K1],
  K3 extends keyof T[K1][K2],
  K4 extends keyof T[K1][K2][K3],
  K5 extends keyof T[K1][K2][K3][K4]
> = [K1, K2, K3, K4, K5];
export type PathOfLength6<
  T,
  K1 extends keyof T,
  K2 extends keyof T[K1],
  K3 extends keyof T[K1][K2],
  K4 extends keyof T[K1][K2][K3],
  K5 extends keyof T[K1][K2][K3][K4],
  K6 extends keyof T[K1][K2][K3][K4][K5]
> = [K1, K2, K3, K4, K5, K6];
export type PathOfLength7Plus<
  T,
  K1 extends keyof T,
  K2 extends keyof T[K1],
  K3 extends keyof T[K1][K2],
  K4 extends keyof T[K1][K2][K3],
  K5 extends keyof T[K1][K2][K3][K4],
  K6 extends keyof T[K1][K2][K3][K4][K5],
  K7 extends keyof T[K1][K2][K3][K4][K5][K6]
> = [K1, K2, K3, K4, K5, K6, K7, ...(number | string)[]];

/**
 * A function to validate that a path is valid for a variable of type T.
 * It simply returns the path itself, and doesn't do anything. The main value
 * is that it infers the types via function overloading.
 *
 * The best way to use this is to not explicitly state any types in the
 * generics and to pass a dummy value of the type the path should apply to
 * and a path.
 *
 * If the path is invalid, it won't typecheck. Unfortunately, the autocomplete
 * for `path` doens't work super well though.
 */
export function validatePath<T, K1 extends keyof T>(
  _dummyValue: T,
  path: PathOfLength1<T, K1>,
): PathOfLength1<T, K1>;

export function validatePath<T, K1 extends keyof T, K2 extends keyof T[K1]>(
  _dummyValue: T,
  path: PathOfLength2<T, K1, K2>,
): PathOfLength2<T, K1, K2>;

export function validatePath<
  T,
  K1 extends keyof T,
  K2 extends keyof T[K1],
  K3 extends keyof T[K1][K2]
>(
  _dummyValue: T,
  path: PathOfLength3<T, K1, K2, K3>,
): PathOfLength3<T, K1, K2, K3>;

export function validatePath<
  T,
  K1 extends keyof T,
  K2 extends keyof T[K1],
  K3 extends keyof T[K1][K2],
  K4 extends keyof T[K1][K2][K3]
>(
  _dummyValue: T,
  path: PathOfLength4<T, K1, K2, K3, K4>,
): PathOfLength4<T, K1, K2, K3, K4>;

export function validatePath<
  T,
  K1 extends keyof T,
  K2 extends keyof T[K1],
  K3 extends keyof T[K1][K2],
  K4 extends keyof T[K1][K2][K3],
  K5 extends keyof T[K1][K2][K3][K4]
>(
  _dummyValue: T,
  path: PathOfLength5<T, K1, K2, K3, K4, K5>,
): PathOfLength5<T, K1, K2, K3, K4, K5>;

export function validatePath<
  T,
  K1 extends keyof T,
  K2 extends keyof T[K1],
  K3 extends keyof T[K1][K2],
  K4 extends keyof T[K1][K2][K3],
  K5 extends keyof T[K1][K2][K3][K4],
  K6 extends keyof T[K1][K2][K3][K4][K5]
>(
  _dummyValue: T,
  path: PathOfLength6<T, K1, K2, K3, K4, K5, K6>,
): PathOfLength6<T, K1, K2, K3, K4, K5, K6>;

export function validatePath<
  T,
  K1 extends keyof T,
  K2 extends keyof T[K1],
  K3 extends keyof T[K1][K2],
  K4 extends keyof T[K1][K2][K3],
  K5 extends keyof T[K1][K2][K3][K4],
  K6 extends keyof T[K1][K2][K3][K4][K5],
  K7 extends keyof T[K1][K2][K3][K4][K5][K6]
>(
  _dummyValue: T,
  path: PathOfLength7Plus<T, K1, K2, K3, K4, K5, K6, K7>,
): PathOfLength7Plus<T, K1, K2, K3, K4, K5, K6, K7>;

export function validatePath<T>(_dummyValue: T, path: unknown): unknown {
  return path;
}

@lolmaus
Copy link

lolmaus commented Apr 24, 2020

This looks nice: https://github.com/bsalex/typed-path/

The best part about it is that it allows using with any pre-existing methods that accept property paths.

But the syntax is so verbose and hard to understand that it defeats the purpose.

Instead of

foo.mapBy('a.b.c')

I'd rather do

foo.map(e => e.a.b.c)

than

foo.mapBy(tp<Foo>().a.b.c.toString())

@agalazis
Copy link

agalazis commented Apr 29, 2020 via email

@lostpebble
Copy link

Thanks all for the suggestions. They're unfortunately all so hacky and verbose, and sometimes unusable in certain situations. I've tried implementing them, and its as if one day they work and the next day they don't (maybe TypeScript version changes etc.). They also very complex and super difficult to debug.

I really just wish the TypeScript team would add an ObjectKeyPath<T> utility function, which is an array of variable length but each item conforms to the deep key path of object T.

This kind of functionality is so important in JavaScript and JSON, especially for JSON Patch kind of functionality.

immer, one of my favorite JavaScript libraries, implements "patches", a kind of diff between object operations, which makes use of object deep paths to show which parts have changed. I'd really like to take advantage of this functionality in my own libraries and have it nicely typed for the user.

There are many good uses for such functionality. Big one being smart undo / redo of changes to an object structure.

I wish the TypeScript team would care a lil more about this type. It seems to be quite a non-prioritized thing sadly...

@ccorcos
Copy link

ccorcos commented Jun 5, 2020

I totally much agree. Seems like this could be implemented internally with much better success. Any thoughts? @mhegazy @RyanCavanaugh

@jameslaneconkling
Copy link

FWIW, ts-toolbelt's Object.Path type has been very effective at properly typing most non-trivial use cases I've thrown at it:

import { O } from 'ts-toolbelt'

type T = {
  a: {
    b: { c: number } | { c: string },
    d: { e: boolean }[]
  }
}

type C = O.Path<T, ['a', 'b', 'c']> // type C = string | number
type E = O.Path<T, ['a', 'd', 0, 'e']> // type E = boolean
type F = O.Path<T, ['a', 'b', 'c', 'f']> // type F = never
type G = O.Path<T, ['g']> // type G = never

An approach to typing a path function (with the unfortunate caveat that the function is variadic, rather than taking an array as the second argument) could look like:

declare const path: <T extends object, P extends (string | number)[]>(value: T, ...path: P) => O.Path<T, P>
declare const t: T
const c = path(t, 'a', 'b', 'c') // c: string | number

Not a complete solution, but it's been more reliable than other approaches I've taken.

@hrsh7th
Copy link

hrsh7th commented Sep 1, 2020

#40336

@aigoncharov
Copy link

aigoncharov commented Dec 25, 2020

Guys, I think I made it work with the new recursive types

type ExtractObj<S extends object, K> = K extends keyof S ? S[K] : never

type Path<S extends object, T extends readonly unknown[]> =
    T extends readonly [infer T0, ...infer TR]
    ? TR extends []
        ? ExtractObj<S, T0> extends never
            ? readonly []
            : readonly [T0]
        : ExtractObj<S, T0> extends object
            ? readonly [T0, ...Path<ExtractObj<S, T0>, TR>]
            : ExtractObj<S, T0> extends never
                ? readonly []
                : readonly [T0]
    : readonly []

class Store<S extends object> {
    subscribe<T extends readonly unknown[]>(path: T extends Path<S, T> ? T : never) {}
}

type StoreExample = {
    prop1: {
        nested1: {
            nested1a: string
            nested1b: number
        }
    }
    prop2: {
        nested20: boolean
        nested21: {
            nested21a: number
        }
    }
}

const store = new Store<StoreExample>()

// Valid
store.subscribe(['prop1'] as const)
store.subscribe(['prop1', 'nested1', 'nested1a'] as const)
store.subscribe(['prop2', 'nested20'] as const)
store.subscribe(['prop2', 'nested21', 'nested21a'] as const)

// Invalid
store.subscribe(['prop3'] as const)
store.subscribe(['prop1', 'nested20'] as const)
store.subscribe(['prop1', 'nested1', 'nested1a', 'something'] as const)

Playground

@noahseger
Copy link

@aigoncharov thanks for this! It appears you can also remove the need for as const with this change to the signature:

- subscribe<T extends readonly unknown[]>(path: T extends Path<S, T> ? T : never) {}
+ subscribe<T extends readonly [keyof S, ...unknown[]]>(path: T extends Path<S, T> ? T : never) {}

Updated Playground

@franz101
Copy link

franz101 commented Oct 2, 2021

similiar here:
sindresorhus/type-fest#158

@mqliutie
Copy link

Guys, I think I made it work with the new recursive types

type ExtractObj<S extends object, K> = K extends keyof S ? S[K] : never

type Path<S extends object, T extends readonly unknown[]> =
    T extends readonly [infer T0, ...infer TR]
    ? TR extends []
        ? ExtractObj<S, T0> extends never
            ? readonly []
            : readonly [T0]
        : ExtractObj<S, T0> extends object
            ? readonly [T0, ...Path<ExtractObj<S, T0>, TR>]
            : ExtractObj<S, T0> extends never
                ? readonly []
                : readonly [T0]
    : readonly []

class Store<S extends object> {
    subscribe<T extends readonly unknown[]>(path: T extends Path<S, T> ? T : never) {}
}

type StoreExample = {
    prop1: {
        nested1: {
            nested1a: string
            nested1b: number
        }
    }
    prop2: {
        nested20: boolean
        nested21: {
            nested21a: number
        }
    }
}

const store = new Store<StoreExample>()

// Valid
store.subscribe(['prop1'] as const)
store.subscribe(['prop1', 'nested1', 'nested1a'] as const)
store.subscribe(['prop2', 'nested20'] as const)
store.subscribe(['prop2', 'nested21', 'nested21a'] as const)

// Invalid
store.subscribe(['prop3'] as const)
store.subscribe(['prop1', 'nested20'] as const)
store.subscribe(['prop1', 'nested1', 'nested1a', 'something'] as const)

Playground

niubility

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests