Home > front end >  Method type that takes path segments as arguments and returns the type at the end of the path
Method type that takes path segments as arguments and returns the type at the end of the path

Time:11-02

I'm writing a little RPC library for web workers and it requires the consumer to traverse remote references. To access a reference you have to use IReference.property(...path: string[]).

As an example if I have a source object that looks like { foo: { bar: { value: 'foobar' }}}

Then I would access the internal value with await ref.property('foo', 'bar', 'value').value()

What I'd like is for the return value of .value() to be a Promise to the value.

I've managed to write a type that allows me to have one path segment in the property method, but how do I add more?

export interface IReference<T> {
  property<K extends keyof T | ((...args: any) => any)>(key: K): K extends keyof T ? IReference<T[K]> : any;
  value(): T extends (...args: any) => any ? any : Promise<T>;
}

const data = { foo: { bar: { value: 'foobar' }}}
declare const ref0: IReference<typeof data>

const ref1 = ref0.property('foo', 'bar', 'value')
const value = await ref1.value() // should be string

TypeScript Playground

CodePudding user response:

You can use recursive conditional types to traverse a tuple of paths and retrieve the target - recursing until we're out of paths.

I'm not quite sure what the signature of value() is for in your example - I believe this can just be Promise, but maybe I'm missing something.

Edit - updated to add autocomplete. Based on some responses given by @jcalz on his answers to these which I've tweaked to add comments and names for parameters.

TypeScript type definition for an object property path

The gist here is that we need to build up a union of tuples that contain sequences of valid property accessors - i.e for

{ a: { b: { c: 'foo' } } }

You have the following tuples:

['a']
['a', 'b']
['a', 'b', 'c']

The other thing to note is that with any recursive type need some sort of exit criteria so the compiler knows you're not going to traverse infinitely. Given we're digging into an object - rather than iterating an array - we can use a counter and decrement it every time we recurse. When the counter reaches the final value never - we abandon the recursion.

Here the default limit if not provided is set to 10. (RecursiveDepthCounter extends number = 10).


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

type Paths<Target extends object, RecursiveDepthCounter extends number = 10> =
    // Check if we've hit recursive depth, if so, bail
    [RecursiveDepthCounter] extends [never]
        ? never
        : {
            [key in keyof Target]: Target[key] extends infer TargetChild
                ? TargetChild extends object
                // If we have an object at TargetChild, then we can either access this object ([key])
                // or we need to recurse by calling Paths again, decrementing our recursive depth counter and appending
                // to the tuple of acceptable keys
                    ? [key] | [key, ...Paths<TargetChild, PreviousNumber[RecursiveDepthCounter]>]
                    // If we don't have an object, only key is permissable
                    : [key]
                // If we can't infer Target[key], only allow [key]
                : [key]
            // Access resulting object via keyof Target to remove nesting
        }[keyof Target];

type PropertyAtPath<Target extends unknown, Path extends readonly unknown[]> =
    // Base recursive case, no more paths to traverse
    Path extends [] 
        // Return target
        ? Target
        // Here we have 1 or more paths to access
        : Path extends [infer TargetPath, ...infer RemainingPaths]
            // Check Target can be accessed via this path
            ? TargetPath extends keyof Target 
                // Recurse and grab paths
                ? PropertyAtPath<Target[TargetPath], RemainingPaths>
                // Target path is not keyof Target
                : never
            // Paths could not be destructured
            : never;

export type IReference<T extends object> = {
    property<P extends Paths<T>>(...paths: [...P]): IReference<PropertyAtPath<T, P>>;
    value(): Promise<T>;
}

const data = { foo: { bar: { value: 'foobar' } } };
declare const ref0: IReference<typeof data>;

const ref1 = ref0.property('foo', 'bar');
const value = await ref1.value() // is string

TS playground: hhttps://tsplay.dev/WGkyoW

  • Related