Event callback type narrowing based on the specific event key

While exploring different approaches to create a type-safe event emitter, I came across a pattern where you start by defining your event names and their corresponding types in an interface, as shown below:

interface UserEvents {
    nameChanged: string;
    phoneChanged: number;
}

The 'on' method is then designed to accept a keyof UserEvents and a callback function that takes a parameter of the type associated with that key using lookup types - UserEvents[keyof T]:

function on<T extends keyof UserEvents>(e: T, cb: (val: UserEvents[T]) => void) {/**/}

This setup enables type-safe method calls as demonstrated below:

on('nameChanged', (val) => {}); // callback parameter inferred as string
on('phoneChanged', (val) => {}); // ... inferred as number

However, I encountered an issue within the 'on' method implementation where I struggled to narrow down the type of the provided callback based on the passed key value. Despite attempting to perform type checks against the key type, I found that the callback type remained untouched, merging both possible types:

function on<T extends keyof UserEvents>(e: T, cb: (_: UserEvents[T]) => void) {
    if (e === 'nameChanged') {
        // expected cb type: (string) => void
    } else {
        // should be: (number) => void
    }

    // unfortunately, the cb type inside this function resolves to: (string & number) => void
}

Is there a way to automatically infer the callback type based on the event key using TypeScript features like type guards or discriminated unions while maintaining the specific method call signature?

Answer №1

To achieve the desired functionality, it is essential to consolidate both elements into a single data structure. By utilizing a tuple for this purpose and combining it with spreading function arguments, the outcome will precisely meet the requirements.

Take a look at the following code snippet:

interface UserEvents {
    nameChanged: string;
    phoneChanged: number;
}

// Declaring a generic type to be used with various objects besides UserEvents
type KeyWithCallback<A extends object> = {
  [K in keyof A]: [K, (_: A[K]) => void]
}[keyof A];

function on(...args: KeyWithCallback<UserEvents>) {
  if (args[0] === 'nameChanged') {
    const [_, clb] = args; // Destructuring within a condition block
    clb('') // The callback can accept only strings here (string) => void
  } else {
    const [_, clb] = args; // Destructuring within a condition block
    clb(1); // The callback can accept only numbers here (number) => void
  }
}

// Example of usage
on('nameChanged', (a:string) => {}) // Valid call
on('nameChanged', (a:number) => {}) // Expected error due to incompatible argument

A mapped type named KeyWithCallback has been employed to denote all possible arguments as [key, callback] pairs, resulting in a union of pairs (2nd tuple).

The crucial step is to specify function arguments like

...args: KeyWithCallback<UserEvents>
. This informs the function body that there are two arguments where one is associated with the other, as indicated by each pair in the union type.

By checking the first element of each pair, the second element is automatically inferred.

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

JavaScript and TypeScript: Best practice for maintaining an Error's origin

Coming from a Java developer background, I am relatively new to JavaScript/TypeScript. Is there a standard approach for handling and preserving the cause of an Error in JavaScript/TypeScript? I aim to obtain a comprehensive stack trace when wrapping an E ...

What are some ways to specialize a generic class during its creation in TypeScript?

I have a unique class method called continue(). This method takes a callback and returns the same type of value as the given callback. Here's an example: function continue<T>(callback: () => T): T { // ... } Now, I'm creating a clas ...

Adding 30 Days to a Date in Typescript

Discovering Typescript for the first time, I'm attempting to calculate a date that is (X) months or days from now and format it as newDate below... When trying to add one month: const dateObj = new Date(); const month = dateObj.getUTCMonth() + 2; con ...

TypeScript Error: The Object prototype must be an Object or null, it cannot be undefined

Just recently, I delved into TypeScript and attempted to convert a JavaScript code to TypeScript while incorporating more object-oriented features. However, I encountered an issue when trying to execute it with cmd using the ns-node command. private usern ...

Having trouble with an Angular standalone component? Remember, `imports` should consist of an array containing components, directives, pipes, or NgModules

Recently, I upgraded my application to Angular v15 and decided to refactor a component to make it Standalone. However, when I tried importing dependencies into this component, I encountered the following error: 'imports' must be an array of co ...

Managing errors with async/await in an Angular HttpClient function

I have been experimenting with an async/await pattern to manage a complex scenario that could potentially result in "callback hell" if approached differently. Below is a simplified version of the code. The actual implementation involves approximately 5 co ...

What is the proper way to type a collection and put it into action?

I am looking for a way to create an object that mimics a set. Specifically, I want the transaction id to act as a key and the transaction details as the value. To achieve this, I created the following: type TransactionDetail = { [key: TransactionId]: Tra ...

Setting up an empty array as a field in Prisma initially will not function as intended

In my current project using React Typescript Next and prisma, I encountered an issue while trying to create a user with an initially empty array for the playlists field. Even though there were no errors shown, upon refreshing the database (mongodb), the pl ...

Angular 13: How to Handle an Empty FormData Object When Uploading Multiple Images

I attempted to upload multiple images using "angular 13", but I'm unable to retrieve the uploaded file in the payload. The formData appears empty in the console. Any suggestions on how to resolve this issue? Here is the HTML code: <form [formGro ...

Why do I keep encountering the error "global is not defined" when using Angular with amazon-cognito-identity-js?

To start, run these commands in the command line: ng new sandbox cd .\sandbox\ ng serve Now, navigate to http://localhost:4200/. The application should be up and running. npm install --save amazon-cognito-identity-js In the file \src&bso ...

Attempting to execute a post request followed by a get request

I need assistance optimizing my code. What I am trying to achieve is to create a user (partner) and upon completion of the post request, fetch all partners from an API. This includes the newly created partner so that I can access their ID to use in subsequ ...

The declaration file for the 'react' module could not be located

I was exploring Microsoft's guide on TypeScript combined with React and Redux. After executing the command: npm install -S redux react-redux @types/react-redux I encountered an error when running npm run start: Type error: Could not find a decla ...

Locate every instance where two arrays are compared in TypeScript

My goal is to search for matches in Object 2 where the _type corresponds to filterByCallTypeTitulo in Object 1, and then create a new array including all the matched information from Object 2. I attempted to achieve this using the filter() method and forE ...

app-root component is not populating properly

As a newcomer to Angular 2, I have embarked on a small project with the following files: app.module.ts import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { MaterialModule } fro ...

Tips for integrating and utilizing the MSAL (Microsoft Authentication Library for JavaScript) effectively in a TypeScript-based React single page application

Issue I'm encountering difficulties importing the MSAL library into my TypeScript code. I am using the MSAL for JS library with typings in a basic TypeScript/React project created using create-react-app with react-typescript scripts. As someone who i ...

Using NodeJS API gateway to transfer image files to S3 storage

I have been attempting to upload an image file to S3 through API Gateway. The process involves a POST method where the body accepts the image file using form-data. I crafted the lambda function in TypeScript utilizing the lambda-multipart-parser. While it ...

Enhance Material UI with custom properties

Is it possible to add custom props to a Material UI component? I am looking to include additional props beyond what is provided by the API for a specific component. For example, when using Link: https://material-ui.com/api/link/ According to the document ...

After filling a Set with asynchronous callbacks, attempting to iterate over it with a for-of loop does not accept using .entries() as an Array

Encountering issues with utilizing a Set populated asynchronously: const MaterialType_Requests_FromESI$ = SDE_REACTIONDATA.map(data => this.ESI.ReturnsType_AtId(data.materialTypeID)); let MaterialCollectionSet: Set<string> = new Set<s ...

Express encounters difficulty in processing Chunked Post Data

I am currently retrieving data from a Campbell Scientific data logger. This data is being posted to an application that is coded in Typescript using Express and BodyParser. The request successfully reaches the app (as I'm able to debug it), however, t ...

Create a circle surrounding the latitude and longitude on a Bing Maps angular display

I successfully integrated the Bing map with Angular using the angular-map package, and now I want to draw a circle around a given latitude and longitude. To achieve this, I installed the following npm packages: npm install --save angular-maps npm ins ...