1

Sorry if my title could be better, I have only been using typescript for about 2 weeks so my vernacular is probably lacking.

I have a structure that is 2 levels deep. For simplicity sake, let's image it such as:

type Structure = {
  foo: {
    a: 1,
    b: 2,
  }
  bar: {
    c: 3,
    d: 4,
  }
}

I have a function that takes a string like "foo.a" and returns that leaf of the structure.

The ultimate goal is to be type safe so that if I run:

const x = lookup("foo.a") // x inferred as 1

And TypeScript will know that "foo.a" is valid and the return type is 1.

I was able to create a type like this:

type Branch = keyof Structure
type Section<B extends Branch> = keyof Structure[B] & string

type Path<B extends Branch, S extends Section<B>> = `${B}.${S}`

This works but I cannot use it like so:

const path:Path = 'foo.a'

I must instead use it like:

const path:Path<"foo","a"> = 'foo.a'

This doesn't help me.

I also tried something like:

type SectionPath = `${Branch}.${Section<Branch>}`

But this becomes never and I am assuming it is never because it would create a union that includes impossibilities like "foo.c" or "bar.a" which are invalid. Is there a way to just get the union of possibilities, in this case: "foo.a" | "foo.b" | "bar.c" | "bar.d"

I know I can define this union manually, but the real structure is much larger and would be hard to maintain them both.

Also once I have this union, and I can accept it as an argument in a function, bonus points if you can tell me how to make the function's return type, the correct type for that leaf of the structure!

JD Isaacks
  • 56,088
  • 93
  • 276
  • 422
  • Please take a look on my answer https://stackoverflow.com/questions/67242871/declare-a-type-that-allows-all-parts-of-all-levels-of-another-type#answer-67247652 and my blog https://catchts.com/deep-pick . Does it helpful? – captain-yossarian from Ukraine Aug 05 '21 at 15:28
  • @captain-yossarian your link and article are both helpful, thanks. However in your solutions. You are still limited to specifying each branch/leaf as separate arguments. Using your examples I would have to implement as `lookup("foo", "a")`. Is it just not possible to implement as `lookup("foo.a")` ? – JD Isaacks Aug 05 '21 at 18:09
  • Then you need to create mapping typr 'foo' string to foo object. You still need to tell TS what is 'foo' – captain-yossarian from Ukraine Aug 05 '21 at 18:31
  • Could you please provide more examples of what you need? – captain-yossarian from Ukraine Aug 05 '21 at 18:32
  • @captain-yossarian Ultimately I want my function to accept a list. So I think it would be easier to specify it like: `["foo.a", "bar.d"] -> Partial< typeof foo.a & typeof foo.b >` Instead of having to use an array of arrays like: `[["foo", "a"],["bar","d"]]` Does that make sense? – JD Isaacks Aug 05 '21 at 18:56
  • I have provided an answer. Please help me to help you. Tell me what do you expect on my example – captain-yossarian from Ukraine Aug 05 '21 at 19:30

2 Answers2

2
type Structure = {
    foo: {
        a: 1,
        b: 2,
    }
    bar: {
        c: 3,
        d: 4,
    }
}

type Values<T> = T[keyof T]

type Elem = string;

type Acc = Record<string, any>

/**
 * Just like Array.prototype.reduce predicate/callback
 * Receives accumulator and current element
 * - if element extends one of accumulators keys -> return  acc[elem]
 * - otherwise return accumulator
 */
type Predicate<Accumulator extends Acc, El extends Elem> =
    El extends keyof Accumulator ? Accumulator[El] : Accumulator

type Reducer<
    Keys extends Elem,
    Accumulator extends Acc = {}
    > =
    /**
     * If Keys extends a string with dot
     */
    Keys extends `${infer Prop}.${infer Rest}`
    /**
     * - Call Reducer recursively with last property
     */
    ? Reducer<Rest, Predicate<Accumulator, Prop>>
    /**
     *  - Otherwise obtain wjole properrty 
     */
    : Keys extends `${infer Last}`
    ? Predicate<Accumulator, Last>
    : never
{

    type Test1 = Reducer<'foo.a', Structure> // 1
    type Test2 = Reducer<'bar.d', Structure> // 4
}

/**
 * Compute all possible property combinations
 */
type KeysUnion<T, Cache extends string = ''> =
    /**
     * If T extends string | number | symbol -> return Cache, this is the end
     */
    T extends PropertyKey ? Cache : {
        /**
         * Otherwise, iterate through keys of T, because T is an object
         */
        [P in keyof T]:
        /**
         * Check if property extends string
         */
        P extends string
        /**
         * Check if it is the first call of this utility,
         * because CAche is empty
         */
        ? Cache extends ''
        /**
         * If it is a first call,
         * call recursively itself, go one level down - T[P] and initialize Cache - `${P}`
         */
        ? KeysUnion<T[P], `${P}`>
        /**
         * If it is not first call of KeysUnion and not the last
         * Unionize Cache with recursive call, go one level dow and update Cache
         */
        : Cache | KeysUnion<T[P], `${Cache}.${P}`>
        : never
    }[keyof T]

{
    //"foo" | "bar" | "foo.a" | "foo.b" | "bar.c" | "bar.d"
    type Test1 = KeysUnion<Structure>
}

const deepPick = <Obj,>(obj: Obj) =>
    <Keys extends KeysUnion<Obj>>(keys: Keys): Reducer<Keys, Obj> =>
        null as any

declare var data: Structure;

const lookup = deepPick(data)

const result = lookup('foo.a') // 1

// "foo" | "bar" | "foo.a" | "foo.b" | "bar.c" | "bar.d"
type Path = KeysUnion<Structure>

Playground

More about iteration over the tuple you can find in my article here

More explanation about typing this function you can find in my article here

2

Here's a solution: the helper type FlatValue gives the leaf type for a fully-qualified key, and the helper type FlatKeys gives the fully-qualified keys. It should work for arbitrary nesting depths.

type FlatValue<T, K> =
    K extends `${infer I}.${infer J}` ? I extends keyof T ? FlatValue<T[I], J> : never
    : K extends keyof T ? T[K] : never
type FlatKeys<T, P extends string = ''> =
    T extends object
    ? {[K in keyof T]: FlatKeys<T[K], `${P}${P extends '' ? '' : '.'}${K & string}`>}[keyof T]
    : P
type Flatten<T> = {[K in FlatKeys<T>]: FlatValue<T, K>}

Usage:

/* type Test = {
    "foo.a": 1;
    "foo.b": 2;
    "bar.c": 3;
    "bar.d": 4;
} */
type Test = Flatten<Structure>

Usage for a function:

function test<K extends FlatKeys<Structure>>(k: K): FlatValue<Structure, K> {
    // ...
}
// const result: 1
const result = test('foo.a');

Playground Link

kaya3
  • 47,440
  • 4
  • 68
  • 97
  • This is great too! And currently easier for me to understand. Thanks! – JD Isaacks Aug 06 '21 at 14:30
  • Just come across this and is exactly what I need. I've noticed a slight issue where if you set `foo` to option (`foo?: { a: 1, b: 2 }`), it doesn't match the types correctly. Do you have a way around this? – jackdomleo7 Aug 17 '22 at 14:57
  • @jackdomleo7 It's not clear what you would want the result to be in that case, since `foo.a` isn't guaranteed to exist. – kaya3 Aug 17 '22 at 16:03
  • @kaya3 Yeah that makes sense. I guess what I'm doing is flattening a type to create optional parameters, hence the query about making them optional. No worries – jackdomleo7 Aug 17 '22 at 16:06