3

I'm creating a function like Lodash's at(). I have typing working if the user passes in tuples like this:

at(obj, ['key1'] as const, ['key2', 'key3'] as const)

I want the user to be able to call the function naturally, without tricks like "as const". Can it be done? Here is the craziness I have so far:

type PropertyAtPath<T, Path extends readonly any[]> = Path extends []
  ? T
  : Path extends readonly [infer First, ...infer Rest]
  ? First extends keyof T
    ? PropertyAtPath<T[First], Rest>
    : undefined
  : unknown;

type At<T, Paths extends ReadonlyArray<ReadonlyArray<any>>> = {
  [I in keyof Paths]: Paths[I] extends readonly any[]
    ? PropertyAtPath<T, Paths[I]>
    : never;
};

declare function at<T, Paths extends ReadonlyArray<ReadonlyArray<any>>>(
  object: T,
  ...paths: Paths
): At<T, Paths>;

playground link

2 Answers 2

4

In case someone stumbles upon this in the future.

If you just want to have the function infer a tuple instead of an array without using as const, there are multiple options.


The preferred option is to use a variadic tuple type:

function fn<T extends string[]>(tuple: [...T]) { return tuple }

const r = fn(["a", "b", "c"])
//    ^? const r: ["a", "b", "c"]

There is also this alternative:

function fn<T extends string[] | [string]>(tuple: T) { return tuple }

This behaves identical to the first example in most cases. However I encountered some differences in the past where the second solution works but the first one does not.


Playground

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

1 Comment

The variadic tuple type inferrence without as const only works when constraining the type. Is there a more general solution without the constraint ? e.g. something similar to T extends any[] that still infers?
3

First of all, you need to validate second argument, to avoid passing invalid object paths.

Here, here, here and here, in my blog you can find an explanation of how this code works.

First three links are from stackoverflow. I have provided explanation in comments.

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

declare var data: Structure;

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 Callback<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, Callback<Accumulator, Prop>>
    /**
     *  - Otherwise obtain whole property 
     */
    : Keys extends `${infer Last}`
    ? Callback<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>
}

type ExtractPath<T extends string> = Extract<T, string>

type Mapper<Obj, Paths extends ExtractPath<KeysUnion<Obj>>[]> = {
    [Prop in keyof Paths]: Reducer<Paths[Prop] & string, Obj>
}

const at = <
    Obj,
    Key extends ExtractPath<KeysUnion<Obj>> & string,
    Keys extends Key[]
>(obj: Obj, keys: [...Keys]): Mapper<Obj, Keys> =>
    null as any


type Result = Mapper<Structure, ['foo.a.1', 'bar.c']>

const lookup = at(data, ['foo.a.0', 'bar.c']) // [1, 3]

Playground

Above code expects obj to be fully infered, I mean obj should be as const.

If you want to handle arrays and empty tuples you might want to use this implementation:



type Values<T> = T[keyof T]
{
    // 1 | "John"
    type _ = Values<{ age: 1, name: 'John' }>
}

type IsNever<T> = [T] extends [never] ? true : false;
{
    type _ = IsNever<never> // true 
    type __ = IsNever<true> // false
}

type IsTuple<T> =
    (T extends Array<any> ?
        (T['length'] extends number
            ? (number extends T['length']
                ? false
                : true)
            : true)
        : false)
{
    type _ = IsTuple<[1, 2]> // true
    type __ = IsTuple<number[]> // false
    type ___ = IsTuple<{ length: 2 }> // false
}

type IsEmptyTuple<T extends Array<any>> = T['length'] extends 0 ? true : false
{
    type _ = IsEmptyTuple<[]> // true
    type __ = IsEmptyTuple<[1]> // false
    type ___ = IsEmptyTuple<number[]> // false
}

/**
 * If Cache is empty return Prop without dot,
 * to avoid ".user"
 */
type Concat<
    Cache extends PropertyKey[],
    Prop extends string | number | symbol
    > =
    Cache extends []
    ? [Prop]
    : [...Cache, Prop]

/**
 * Simple iteration through object properties
 */
type HandleObject<Obj, Cache extends PropertyKey[]> = {
    [Prop in keyof Obj]:
    | Cache
    // concat previous Cacha and Prop
    | Concat<Cache, Prop>
    // with next Cache and Prop
    | Path<Obj[Prop], Concat<Cache, Prop>>
}[keyof Obj]

type Path<Obj, Cache extends PropertyKey[] = []> =
    (Obj extends PropertyKey
        // return Cache
        ? Cache
        // if Obj is Array (can be array, tuple, empty tuple)
        : (Obj extends Array<any>
            // and is tuple
            ? (IsTuple<Obj> extends true
                // and tuple is empty
                ? (IsEmptyTuple<Obj> extends true
                    // call recursively Path with `-1` as an allowed index
                    ? Path<PropertyKey, Concat<Cache, -1>>
                    // if tuple is not empty we can handle it as regular object
                    : HandleObject<Obj, Cache>)
                // if Obj is regular  array call Path with union of all elements
                : Path<Obj[number], [...Cache, `${number}`]>
            )
            // if Obj is neither Array nor Tuple nor Primitive - treat is as object    
            : HandleObject<Obj, Cache>
        )
    )


type Reducer<Obj, Props extends Array<PropertyKey>> =
    Props extends []
    ? Obj
    : (Props extends [infer Fst, ...infer Tail]
        ? (Tail extends string[]
            ? (
                Obj extends Array<any>
                ? (
                    Fst extends `${number}`
                    ? Reducer<Obj[number], Tail>
                    : never)
                : (Fst extends keyof Obj
                    ? Reducer<Obj[Fst], Tail>
                    : never
                )
            )
            : never
        )
        : never
    )

type Validation<T> = IsNever<T> extends true ? [never] : []

const at = <
    Obj,
    Keys extends string[]
>(obj: Obj, keys: [...Keys], ...validation: Validation<Reducer<Obj, Keys>>): Reducer<Obj, [...Keys]> =>
    null as any


type Structure = {
    empty: [],
    tuple: [1, 2, 3],
    array: { age: Array<{ surname: string }> }[]
}


declare const data: Structure

/**
 * Tests
 */

// [1, 2, 3]
const _ = at(data, ['tuple'])

// const __: {
//     age: Array<{
//         surname: string;
//     }>;
// }
const __ = at(data, ['array', '0'])

// const ___: {
//     surname: string;
// }[]
const ___ = at(data, ['array', '2', 'age'])

// const ____: {
//     surname: string;
// }
const ____ = at(data, ['array', '0', 'age', '2'])

/**
 * Expected never
 */
{
    const _ = at(data, ['tupl'],) // error
    const __ = at(data, ['array', 'w']) // error
}

Playground

7 Comments

It looks like you've shown an example of how to make the inferred type of a single argument be a tuple. I'm not sure how to use this in my case, where I want each variadic argument to be inferred as a tuple. Is there some part of your answer I can apply to my situation? Also, FYI, that playground link is broken for me.
@EricSimonton my bad. Updated
That's cool stuff. Thank you for taking the time to make, then update your answer. While this brilliantly removes the need for the user to specify as const, it also requires them to provide a dot-separated string path. Can the typing be done so that they can pass in tuples to specifying their paths?
Could you please provide expected behavior? I m not sure I understood
For example remove the as const part of what I put in my question, leading to: at(obj, ['key1'], ['key2', 'key3']). For other examples see lines 30 and 33 in the playground link of my question. I would like the user to be able to call my function like that (without as const or other tricks) and for the typing to still work.
|

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.