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...
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 string
s.
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.