Search code examples
javascripttypescriptobjecttypesnarrowing

Get Narrow Type from Object of Methods


I want to get a Narrowing type like this:

type Expected = {
    method: 'firstNamespace/firstMethod'
    payload: [string]
} | {
    method: 'firstNamespace/secondMethod'
    payload: [number, number]
} | {
    method: 'secondNamespace/firstMethod'
    payload: [number]
}

From an object of methods like this:

const Methods = {
    firstNamespace: {
        firstMethod: (p: string) => {},
        secondMethod: (p: number, k: number) => {}
    },
    secondNamespace: {
        firstMethod: (p: number) => {}
    }
}

I have tried various things but I think there is something I am misunderstanding in TypeScript.

I've been working with TypeScript almost a year and anything that exceeds basic types seems out of my reach...


Solution

  • This is possible in TypeScript via recursive conditional types to drill down into the nested properties of Methods, and template literal types to concatenate the method names together at the type level.

    Let's call the type function you are looking for as MethodsToExpected<T>, which takes an object type T and produces a union of object types whose method property is a "/"-delimited path to each method name and whose payload property is a tuple of the corresponding method's parameter types. Then we can define MethodsToExpected<T> recursively in terms of itself like this:

    type MethodsToExpected<T> = { [K in keyof T]-?:
        T[K] extends (...args: infer P) => any ? { method: K, payload: P } :
        MethodsToExpected<T[K]> extends infer X ? (
            X extends { method: infer M, payload: infer P } ? (
                { method: `${Extract<K, string>}/${Extract<M, string>}`; payload: P }
            ) : never
        ) : never
    }[keyof T]
    

    The construction {[K in keyof T]-?: XXX}[keyof T], which immediately indexes into a mapped type with its keys, producing a union of all the XXX types.

    For each property key K from T, we check the property type T[K]. If it's a function type, then we grab its list of parameter list and immediately return {method: K, payload: P}. Otherwise we apply MethodsToExpected<T[K]> to the property and inspect it.

    (Aside: I'm using conditional type inference to "copy" MethodsToExpected<T[K]> into a new type parameter X, which I then use as the checked type in a distributive conditional type so that any unions in X are distributed to the final union. If you just used MethodsToExpected<T[K]> extends {method: infer M, payload: infer P} instead of the intermediate X, it would produce things like {method: "a/b" | "a/c", payload: [string] | [number]} instead of the desired {method: "a/b", payload: [string]} | {method: "a/c", payload: [number]}.)

    For each union element of MethodsToExpected<T[K]>, we pull out the method type M and the payload type P, and build a new method/payload pair. The payload type doesn't change and is just P, but we prepend the current key K and a slash "/" to the M with a template literal type. The compiler can't be sure that K and M are both string types so it doesn't want to let you write `${K}/${M}` directly. Instead we use the Extract<T, U> utility type to convince the compiler that we will only be concatenating strings.


    Let's see if it works:

    type Expected = MethodsToExpected<typeof Methods>;
    /* type Expected = {
        method: "firstNamespace/firstMethod";
        payload: [p: string];
    } | {
        method: "firstNamespace/secondMethod";
        payload: [p: number, k: number];
    } | {
        method: "secondNamespace/firstMethod";
        payload: [p: number];
    } */
    

    Looks good. Just to be sure that it drills down into nested subproperties, let's try a different one:

    type Nested = MethodsToExpected<{ a: { b: { c: { d: { e: (f: string) => number } } } } }>;
    /* type Nested = {
        method: "a/b/c/d/e";
        payload: [f: string];
    } */
    

    Also good.

    Playground link to code