2

Let's assume we have following constant:

const something = {
  foo: {
    bar: {
      num: 67,
      str: 'str',
    },
  },
  some: {
    prop: 12,
  },
  topProp: 25,
};

The Task:

Implement typechecking for following deep property access function


/**
 * @example
 * getByPath('foo/bar/str'); // returns 'str'
 * 
 * @example
 * getByPath('topProp'); // returns 25
 * 
 * @example
 * getByPath('some/prop'); // returns 12
 */
const getByPath = (path: ComputedUnionType) => {<unrelated-code-magic>};

// Where
type ComputedUnionType = 'foo/bar/num' | 'foo/bar/str' | 'some/prop' | 'topProp';

// or even better
type ComputedUnionType<typeof some> = 'foo/bar/num' | 'foo/bar/str' | 'some/prop' | 'topProp';
const getByPath = <T>(path: ComputedUnionType<T>) => ...

What I did?

  1. Implemented function on getting array of valid paths, but it returns array of simple strings(obviously -_-), so couldn't find any way to use it to enforce types
  2. Read a bunch of articles on enum types, resulting in conslustion that enum types will not help me here, because their property values can only be computed numbers, not strings (and probably they won't help anyway, because their properties itself can not be generated on fly)
  3. Stumbled across this answer on implementing tuples typechecking, but failed to somehow make use of it in my case. It is pretty interesting read, but in general provided solution juggles with existing union types and keys but never computes new ones.

Guesses

  1. Maybe it could be a type that calls itself recursively like deep partial or something like that
type DeepPartial<T> = {
    [P in keyof T]?: DeepPartial<T[P]>;
};
  1. Maybe there is some way to achieve it from the bottom with generic types, taking keyof bar, keyof foo and so on.
Temoncher
  • 644
  • 5
  • 15

2 Answers2

3

When TypeScript 4.1 lands you will be able to manipulate string literal types via template literal types as implemented in microsoft/TypeScript#40336. Here's a possible implementation to convert a type to a union of slash-delimited paths that lead to non-object properties:

type Join<K, P> = K extends string | number ?
    P extends string | number ?
    `${K}${"" extends P ? "" : "/"}${P}`
    : never : never;

type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]

type Leaves<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: Join<K, Leaves<T[K], Prev[D]>> }[keyof T] : "";

I've put explicit recursion limiters here so if you try to do Leaves<SomeTreelikeType> you can choose a max depth. If you don't care you can forget Prev and D and just have

type Leaves<T> = T extends object ?
    { [K in keyof T]-?: Join<K, Leaves<T[K]>> }[keyof T] : "";

This gives you the union you desire:

type ComputedUnionType = Leaves<typeof something>;
// type ComputedUnionType = "topProp" | "foo/bar/str" | "foo/bar/num" | "some/prop"

The part you didn't ask about is how to get the compiler to convert the type of the path to the type of the resulting output. This is also possible (with recursive conditional types as implemented in #40002, also landing in TS4.1), but since you didn't ask I'm not going to spend the time implementing it.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
1

Might as well add my solution as well, which is mostly equivalent to jcalz's unchecked recursion version, but with the Join helper combined. On the example it behaves the same, but it may behave differently if your object has values other than strings and numbers, or if your object type has optional ? keys.

type ComputedUnionType<T, S extends string = '/'> = {
    [K in keyof T]:
        K extends string
            ? ComputedUnionType<T[K]> extends string
                ? `${K}${S}${ComputedUnionType<T[K]>}`
            : K
        : never;
}[keyof T];

type Z = ComputedUnionType<typeof something>;
// type Z = "topProp" | "foo/bar/str" | "foo/bar/num" | "some/prop"

Playground Link

Mingwei Samuel
  • 2,917
  • 1
  • 30
  • 40