2
const val = {
    a: {
        b: {
            c: 1
        }
    }
};

I have a lot of objects with different structure. I have a set of function that need to traverse data in an object. I want to utilize TypeScript to help me ensuring the input is key along the path.

Something like these are all fine:

myFn('a.b.c'); 
myFn('a', 'b', 'c'); 

If user type:

myFn('a.x.c'); 
myFn('a', 'b', 'x'); 

These should be highlighted as error.

Is it possible?

1 Answer 1

4

As for the first example, with dot notation, you can use this example:

type Foo = {
    user: {
        description: {
            name: string;
            surname: string;
        }
    }
}

declare var foo: Foo;

/**
 * Common utils
 */

type Primitives = string | number | symbol;

/**
 * Obtain a union of all values of object
 */
type Values<T> = T[keyof T]

type Elem = string;

type Acc = Record<string, any>

/**
 * Custom user defined typeguard
 */
const hasProperty = <Obj, Prop extends Primitives>(obj: Obj, prop: Prop)
    : obj is Obj & Record<Prop, any> =>
    Object.prototype.hasOwnProperty.call(obj, prop);

/**
 * Obtain value by object property name
 */
type Predicate<Accumulator extends Acc, El extends Elem> =
    El extends keyof Accumulator ? Accumulator[El] : Accumulator

/**
 * If first argument is empty string, avoid using dot (.)
 * If first argument is non empty string concat two arguments andput dot (.)
 * between them
 */
type Concat<Fst, Scd> =
    Fst extends string
    ? Scd extends string
    ? Fst extends ''
    ? `${Scd}`
    : `${Fst}.${Scd}`
    : never
    : never
{
    type Test = Concat<'hello','bye'> // "hello.bye"
}    

/**
 * Obtain union of all possible paths
 */
type KeysUnion<T, Cache extends string = ''> =
    T extends Primitives ? Cache : {
        [P in keyof T]:
        | Concat<Cache, P>
        | KeysUnion<T[P], Concat<Cache, P>>
    }[keyof T]

{
    // "user" | "user.description" | "user.description.name" | "user.description.surname"
    type Test = KeysUnion<Foo>
}

/**
 * Get object value by path
 */
type GetValueByPath<T extends string, Cache extends Acc = {}> =
    T extends `${infer Head}.${infer Tail}`
    ? GetValueByPath<Tail, Predicate<Cache, Head>>
    : Predicate<Cache, T>
{
    // {
    //   name: string;
    //   surname: string;
    // }
    type Test = GetValueByPath<"user.description", Foo>
}

/**
 * Obtain union of all possible values, 
 * including nested values
 */
type ValuesUnion<T, Cache = T> =
    T extends Primitives ? T : Values<{
        [P in keyof T]:
        | Cache | T[P]
        | ValuesUnion<T[P], Cache | T[P]>
    }>

/**
 * Function overloading
 */
function deepPickFinal<Obj, Keys extends KeysUnion<Obj>>
    (obj: ValuesUnion<Obj>, keys: Keys): GetValueByPath<Keys, Obj>

function deepPickFinal<Obj, Keys extends KeysUnion<Obj> & Array<string>>
    (obj: ValuesUnion<Obj>, ...keys: Keys) {
    return keys
        .reduce(
            (acc, elem) => hasProperty(acc, elem) ? acc[elem] : acc,
            obj
        )
}

/**
 * Ok
 */
const result = deepPickFinal(foo, 'user.description') // ok { name: string; surname: string; }
const result2 = deepPickFinal(foo, 'user') // ok

Playground

As you might have noticed, return type is also infered. Full description and explanation, you will find in my article.

If you want to use rest arguments, comma separated, please refer to my article

Sign up to request clarification or add additional context in comments.

3 Comments

I will give it a try. Looks like it is an ingenius piece of code
This is genius and saved me so much time. Thanks so much!
The code in the playground link is corrupted.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.