There are two different ways in which TypeScript can ensure type safety for related types like this.
One method involves using union types, particularly discriminated union types, and then narrowing them through control flow analysis. This approach usually requires a separate code block for each union member, similar to what is done with
if (func === 'funcA') { ⋯ } else { ⋯ }
.
The other method involves utilizing generics, where operations can be performed that the compiler sees as valid for a range of generic type arguments. This typically involves writing a single block of code that is applicable to all types, without breaking it down into specific cases, similar to what happens with
const res = await apiCaller[func](options);
.
Currently, these two approaches are not compatible with each other. If you want to create a single block of code that works universally, you should use generics instead of unions. On the other hand, if you prefer case-specific blocks, then unions would be more suitable than generics.
This limitation regarding the inability to use unions in a single block is discussed in microsoft/TypeScript#30581. A recommended solution involving refactoring to utilize generics is outlined in microsoft/TypeScript#47109.
In this scenario, I will walk you through that refactoring process using your example, in order to implement generic operations that can be followed by the compiler. The compiler excels at executing generic indexing operations represented by indexed access types, allowing for simulated case-by-case code using object property lookup. For a concise line of code like apiCaller[func](options)
to be deemed acceptable, the type of apiCaller[func]
must be a function type such as (arg: XXX) => YYY
, where options
has type
XXX</code. Therefore, <code>apiCaller
must be of a custom type whereby
XXX
is a generic type depending on the type of
func
. Typically, this is accomplished by defining it as a
mapped type over certain "base" mapping object types.
Here's how the implementation looks:
Firstly, let's rename your initial IApiCaller
type:
interface _IApiCaller {
funcA: (options: { optionFuncA: string }) => Promise<{ keyFuncA: 'a' }>;
funcB: (options: { optionFuncB: string }) => Promise<{ keyFuncB: 'b' }>;
}
Next, we define the "base" mapping object types. Firstly, one corresponding to the arguments of the IApiCaller
methods:
type IApiCallerArg = { [K in keyof _IApiCaller]:
Parameters<_IApiCaller[K]>[0]
};
/* type IApiCallerArg = {
funcA: { optionFuncA: string; };
funcB: { optionFuncB: string; };
} */
Then, another type reflecting the return values:
type IApiCallerRet = { [K in keyof _IApiCaller]:
Awaited<ReturnType<_IApiCaller[K]>>
};
/* type IApiCallerRet = {
funcA: { keyFuncA: 'a'; };
funcB: { keyFuncB: 'b'; };
} */
Finally, we redefine IApiCaller
as a mapped type built upon these two new types:
type IApiCaller = { [K in keyof _IApiCaller]:
(options: IApiCallerArg[K]) => Promise<IApiCallerRet[K]>
};
/* type IApiCaller = {
funcA: (options: { optionFuncA: string; }) => Promise<{ keyFuncA: 'a'; }>;
funcB: (options: { optionFuncB: string; }) => Promise<{ keyFuncB: 'b'; }>;
} */
At first glance, you might think, "Isn't this equivalent to the original type I started with?" While they are indeed comparable, the new definition of IApiCaller
is explicitly specified as a mapping across a generic K
key, enabling the compiler to resolve issues within a generic function in a way that wasn't possible with the previous definition. By attempting to swap the new definition with the old one, you'll likely encounter a flurry of errors.
Subsequently, apiCaller
is annotated as the new IApiCaller
type, and it functions seamlessly:
const apiCaller: IApiCaller = {
funcA: (options: { optionFuncA: string }) => Promise.resolve({ keyFuncA: 'a' }),
funcB: (options: { optionFuncB: string }) => Promise.resolve({ keyFuncB: 'b' })
};
Lastly, let's construct your generic helper()
function:
async function helper<K extends keyof IApiCaller>(func: K) {
const optionsMap: IApiCallerArg = {
funcA: { optionFuncA: 'a' },
funcB: { optionFuncB: 'b' }
}
const options = optionsMap[func];
const resultsKeyMap: { [K in keyof IApiCaller]: keyof IApiCallerRet[K] } = {
funcA: 'keyFuncA',
funcB: 'keyFuncB'
}
const resultsKey = resultsKeyMap[func];
const res = await apiCaller[func](options);
const results = res[resultsKey];
return results;
}
Remember, within a generic function, intricate case-by-case analyses cannot be used to determine options
and
resultsKey</code. However, generic property lookups come in handy. Therefore, <code>optionsMap
and
resultsKeyMap
are created with appropriate types so that both can be indexed by
func
to yield a consistently generic output. Consequently,
options
boasts a type of
IApiCallerArg[K]
, while
resultsKey
maintains a type of
keyof IApiCallerRet[K]
.
Hence, await apiCaller[func](options)
compiles error-free; the nature of apiCaller[func](options)
implies Promise<IApiCallerRet[K]>
, leading to res
embodying IApiCallerRet[K]
, and subsequently, results
representing
IApiCallerRet[K][keyof IapiCallerRet[K]]
, sufficiently customized for strong typification:
const a = helper("funcA"); // const a: Promise<"a">
const b = helper("funcB"); // const b: Promise<"b">
Visit the Playground link to code