Is there a method to inform TypeScript that there is a connection between the values of multiple variables?

In my code, I have a function that makes multiple different API calls, but the core logic surrounding these calls remains consistent (error handling, pagination iteration, etc.). To prevent duplicating this logic, I'm attempting to dynamically call a function and access its results by passing the function name as a parameter. However, all my attempts have resulted in type errors. Here's an example:

interface IApiCaller {
    funcA: (options: { optionFuncA: string }) => Promise<{ keyFuncA: 'a' }>;
    funcB: (options: { optionFuncB: string }) => Promise<{ keyFuncB: 'b' }>;
}

const apiCaller: IApiCaller = {
    funcA: (options: { optionFuncA: string }) => Promise.resolve({ keyFuncA: 'a' }),
    funcB: (options: { optionFuncB: string }) => Promise.resolve({ keyFuncB: 'b' })
};

async function helper<Func extends 'funcA' | 'funcB'>(func: Func) {
    let options: Parameters<IApiCaller[Func]>[0],
        resultsKey: keyof Awaited<ReturnType<IApiCaller[Func]>>;

    if (func === 'funcA') {
        options = { optionFuncA: 'a' };
        resultsKey = 'keyFuncA'; // throws error Type 'string' is not assignable to type 'never'
    } else {
        options = { optionFuncB: 'b' };
        resultsKey = 'keyFuncB'; // throws same error as above
    }

    const res = await apiCaller[func](options); // throws error Property 'optionFuncB' is missing in type '{ optionFuncA: string; }' but required in type '{ optionFuncB: string; }'
    const { [resultsKey]: results } = res; 
}

Is there a way to inform TypeScript that the values of variables like options and resultsKey are linked? In other words, can we ensure that these variables will have the appropriate values based on the value of func.

For a live demo with this example, visit the TypeScript playground

Answer №1

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

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

using vuejs, learn how to retrieve properties within the .then function

This is the code I am working on: methods: { async pay() { this.$notify(`working`); if (!this.$v.$invalid) { try { var data = { to: this.to, subject: this.subject, }; let resp ...

Error: The requested resource, youtube#videoListResponse, is currently unavailable

When attempting to access a YouTube playlist that includes private videos, the bot will encounter an error message. Error: unable to locate resource youtube#videoListResponse Below is the code snippet in question: if (url.match(/^https?:\/\/(w ...

Exploring the Chrome Browser Console with JavaScript: Using document.querySelectorAll('div') and accessing the first element with document.querySelectorAll('div')[0]

var element = document.querySelectorAll('div'); console.log(element) alert(11) Initially, the browser displays the console contents and then triggers an alert. To change this behavior, the code can be modified as follows: var element = document ...

Tips for bringing in styles from a .json file to a react component?

I am trying to figure out how to import the width, height, and style properties into a React component. To start, here is an example of a .json document with object properties: "manufacturerData": { "name": "Duesenberg", ...

Utilizing ngModel within the controller of a custom directive in Angular, instead of the link function

Is there a way to require and use ngModel inside the controller of a custom Angular directive without using the link function? I have seen examples that use the link function, but I want to know if it's possible to access ngModel inside the directive ...

Retrieving URL from AJAX Request in Express JS

Currently, I am in the process of developing an Express App and encountering a challenge regarding the storage of the user's URL from which their AJAX request originated. In simpler terms, when a website, such as www.example.com, sends an HTTP request ...

Display/hide the entire div element

I am currently working on a project with 2 divs, where I want to display only one div at a time and hide the other. For example, if div 1 is visible, then div 2 should be hidden. You can check out what I have done so far in this demo. Everything is workin ...

Can Node.js fetch a PHP file using AJAX?

The Challenge: Greetings, I am facing an issue with my website that features a game created using node.js, gulp, and socket.io. The problem arises when I attempt to call a php file from node.js. While the file returns a success message (echo in the file) ...

Unsure of the integration process for using Stripe in conjunction with Firebase on my website

Is it possible to incorporate the Firebase Stripe extension into my web app using Nextjs framework? I have already set up Firebase, but now I am unsure of the next steps. Should I separately set up Stripe in my app or utilize the Firebase SDKs for this pur ...

Why does the variable's value reset to empty when leaving the function?

I encountered an issue with my api request that is included in a function within my Service script. Despite defining the variable "curruser" outside of the function to retain its value, I noticed that it becomes empty after exiting the script. services.js ...

File writing issues are plaguing NodeJS, with middleware failing to function as well

I am facing a challenge with my middleware that is supposed to write JSON data to a file once the user is logged in. However, I am encountering an issue where no data is being written to the file or displayed on the console when the function is executed. ...

Utilizing lodash and Angular 8: Identifying an valid array index then verifying with an if statement

In my current project, I am developing an e-commerce angular application that includes a basket with two types of products: restaurant + show combos and gift cards. When a client reserves a restaurant, they must also reserve a show; conversely, the client ...

In order to utilize Next.js with pkg, you must enable one of the specified parser plugins: 'flow' or 'typescript'

Utilizing next.js with the pkg in my project, following the steps outlined in this tutorial, I encountered an error when running the pkg command: > Error! This experimental syntax requires enabling one of the following parser plugin(s): 'flow, t ...

Creating a JavaScript array in Rails 4 based on current data without the need to reload the webpage

Currently in my rails 4 app, I am working on a unique tags validation using jquery validate to ensure that tags are not duplicated before they can be added to an item. The tag list structure is as follows: <div id="taglist"> <span class="label ...

Customize the label of the model in AngularStrap's typeahead ng-options to display something

Utilizing AngularStrap typeahead for address suggestions, I am facing an issue where I want to set the selected address object as my ng-model, but doing so causes me to lose the ability to display just one property of the object as the label. Here is an e ...

Filter an array containing nested objects based on dynamically determined properties

I'm working with an array of N objects and need to create a filter using JSON.stringify that dynamically checks multiple properties. Looking for a solution that is dynamic and doesn't rely on static properties (as shown in the code snippet above ...

Storing the Google Maps API key as a global variable in a Django project

Is there a way to improve the way I open my gmap on multiple pages? {% block extra_js %} <script src="{%static 'js/map.js' %}" type="text/javascript"></script> <script src="https://maps.googleapis.com/maps/api/js?key=YOUR_ ...

Retrieving data from an AJAX call and populating a knockout.js observableArray()

This phenomenon confuses me. There must be some small detail that I am missing here. My goal is to load a simple observableArray in knockout using an ajax call. javascript // We initialize the array with an empty array. var data = []; var viewModel = ...

Change the hover effects on the desktop to be more mobile-friendly

In my desktop version, I have implemented some code that enables hovering over a button to display additional information or text. How can I modify this for mobile so that nothing happens when the button is tapped on? .credit:hover .credit-text, .credit- ...

Creating a standard change listener for text entry fields utilizing Typescript and React with a dictionary data type

I came across an interesting example of using a single change function to manage multiple text inputs and state changes in React and Typescript. The example demonstrates the use of a standard string type: type MyEntity = { name: string; // can be app ...