Determining function return property type in Typescript by mapping interface argument property

In order to manage messaging between the browser and a web worker, I have developed a generic class. Each side creates a class that can send specific messages and acknowledge them on the other side with a returned result in a payload. The implementation is currently working perfectly, and I've implemented an interface mapping to facilitate message sending.

However, I am facing a challenge when it comes to inferring the return type based on the interface mapping passed as an argument to the function.

Below is a link to the playground showcasing the interfaces, classes, and their implementations.

I am specifically struggling with the 'send' function:

messages.send({ action: MessageAction.Init, data: { scripts: [] } }).then(data => {
  // need to infer the return type from the action.
  console.log(data);
});

Essentially, instead of returning void from the playground, I would like it to automatically determine that 'data' is actually

AcknowledgeMessageData<InitReturn>
because the 'action' property of the argument is 'init' (currently not generic). Therefore, the object structure should look like this:

{
   isSuccess: true,
   result: {
      someRandomProperty: '',
      anotherProperty: 0
   }
}

Different actions would lead to different generic properties for 'result'.

Playground

Currently, I'm unable to figure out how to trigger the inference based on the argument's property and apply it to the return value's property.

Edit: added minimal reproduction

enum Action {
    Ack = 'ack',
    Init = 'init'
}

interface BaseMessage {
    action: Action;
}

interface AckBaseMessage extends BaseMessage {
    action: Action.Ack;
}

interface InitBaseMessage extends BaseMessage {
    action: Action.Init;
}

interface MessageDataMapping {
    [Action.Ack]: AckBaseMessage;
    [Action.Init]: InitBaseMessage;
}

export type Message = { [K in Action]: MessageDataMapping[K] }[Action];

async function send(payload: Message) {
    return thisWouldBeReturnedFromWebWorker(payload);
}

async function thisWouldBeReturnedFromWebWorker(payload: Message): Promise<any> {
    if (payload.action === Action.Init) {
        return {
            action: Action.Init,
            result: {
                somePropertiesHere: true
            }
        };
    }

    return { action: Action.Ack, result: { isSuccess: true } }
}

send({ action: Action.Init }).then(response => {
    response.result.somePropertiesHere

    response.result.isSuccess
});

Answer №1

To tackle this, my approach would involve defining the call signature for send() as a generic function that works on the type parameter K, constrained to Action, which corresponds to the action property of the payload parameter:

declare function send<K extends Action>(payload: Message<K>): Promise<Return<K>>;

Here, the input type is Message<K> and the output type is Promise<Return<K>>. Definitions for Message<K> and Return<K> need to be established. Let's begin with Message<K>:

type Message<K extends Action> =
    { [P in K]: { action: P } }[K]

This type is known as a distributive object type introduced in microsoft/TypeScript#47109. It functions as a mapped type which is immediately indexed into. The goal here is to have Message<Action.Init> represent {action: Action.Init}, and Message<Action.Ack> denote {action: Action.Ack}, while

Message<Action.Init | Action.Ack>
represents the union of Message<Action.Init> and Message<Action.Ack>. This operation distributes over unions within the input.

The definition for Return<K> is slightly more complex as it involves retrieving the result property from a helper interface:

interface ReturnMap {
    [Action.Init]: { somePropertiesHere: boolean };
    [Action.Ack]: { isSuccess: boolean }
}

type Return<K extends Action> = { [P in K]:
    { action: K, result: ReturnMap[K] }
}[K];

Despite the complexity, the concept remains similar. Note that while the current structure of Message<K> may seem intricate for its purpose, additional properties can always be added based on K using something akin to a MessageMap interface, similar to how

ReturnMap</code is utilized.</p>
<hr />
<p>With the defined call signature, calls to <code>send()
now align with your requirements:

send({ action: Action.Init }).then(response => {
    response.result
    //       ^?(property) result: { somePropertiesHere: boolean; }
});
send({ action: Action.Ack }).then(response => {
    response.result
    //       ^?(property) result: { isSuccess: boolean; }
});

However, an issue arises inside the implementation of send() where the compiler fails to recognize the specific return values as the correct generic type:

async function send<K extends Action>(payload: Message<K>): Promise<Return<K>> {
    if (payload.action === Action.Init) {
        return { // error! Type 'Action.Init' is not assignable to type 'K'.
            action: Action.Init,
            result: {
                somePropertiesHere: true
            }
        };
    }

    return { action: Action.Ack, result: { isSuccess: true } } // error!
    //     Type 'Action.Ack' is not assignable to type 'K'.
}

Current limitations in TypeScript prevent the compiler from recognizing changes in the actual value of a generic type K, leading to uncertainties regarding the exact type of the returned value despite narrowing down the type of payload.action. Though there are suggestions for improvement like microsoft/TypeScript#33014, they haven't been incorporated yet.


A workaround involves utilizing type assertions to overcome this hurdle:

async function send<K extends Action>(payload: Message<K>): Promise<Return<K>> {
    if (payload.action === Action.Init) {
        return {
            action: Action.Init,
            result: {
                somePropertiesHere: true
            }
        } as Return<K>;
    }
    return { action: Action.Ack, result: { isSuccess: true } } as Return<K>
}

While this solution suppresses compilation errors by essentially stating "trust me, this return value is of type Return<K>", caution should be exercised to ensure the validity of the assertion.


An alternative workaround involves completing the refactoring process by incorporating both the types and implementations. This strategic update allows for the relationship between input and output types to be implemented effectively:

const ioFuncs: { [P in Action]: (i: Message<P>) => Return<P> } = {
    init: (payload) => ({ action: Action.Init, result: { somePropertiesHere: true } }),
    ack: (payload) => ({ action: Action.Ack, result: { isSuccess: true } });
}

async function send<K extends Action>(payload: Message<K>): Promise<Return<K>> {
    return ioFuncs[payload.action](payload);
}

By explicitly mapping over P in Action, the ioFuncs variable operates based on Message<P> and Return<P>. Ultimately, the implementation of send() entails fetching the appropriate property from ioFuncs and calling it with the payload. Type-checking verifies that ioFuncs[payload.action] adheres to the single type

(i: Message<P>) => Return<P>
. Consequently, as payload is of type Message<P>, the function returns Return<P>.

Although more intricate than type assertions, this method helps detect mistakes that assertions might overlook. In case of errors such as interchanging values within init and ack properties, the compiler will flag and signal the inconsistency:

const badIoFuncs: { [P in Action]: (i: Message<P>) => Return<P> } = {
    ack: (payload) => ({ action: Action.Init, result: { somePropertiesHere: true } }), 
    init: (payload) => ({ action: Action.Ack, result: { isSuccess: true } });
} // errors!

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

JavaScript - Cannot access the 'name' property on an empty object

I am currently following a React tutorial that demonstrates how to create a useForm hook for linking form input to state. Here is the implementation of the hook: const useForm = (initial = {}) => { const [inputs, setInputs] = useState(initial) ...

"Learn the trick of converting a stream into an array seamlessly with RxJs.toArray function without the need to finish the

In order to allow users to filter data by passing IDs, I have created a subject that can send an array of GUIDs: selectedVacancies: Subject<string[]> = new Subject(); selectedVacancies.next(['a00652cd-c11e-465f-ac09-aa4d3ab056c9', ...

Leveraging symbols as object key type in TypeScript

I am attempting to create an object with a symbol as the key type, following MDN's guidance: A symbol value may be used as an identifier for object properties [...] However, when trying to use it as the key property type: type obj = { [key: s ...

Combining two elements in Angular 2

I am looking to find the intersection of two objects. My goal is to compare these objects and if they have matching values on corresponding keys, then I want to add them to a new object. obj1 = { "Projects": [ "test" ], "Companies": [ "facebook", "google ...

What is the best way to incorporate auto-completion into a browser-based editor using Monaco?

Recently, I embarked on a project to develop a browser-based editor using monaco and antlr for a unique programming language. Following an excellent guide, I found at , gave me a great start. While Monaco provides basic suggestions with ctrl + space, I am ...

What does the "start" script do in the package.json file for Angular 2 when running "concurrent "npm run tsc:w" "npm run lite"" command?

What is the purpose of concurrent in this code snippet? "scripts": { "tsc": "tsc", "tsc:w": "tsc -w", "lite": "lite-server", "start": "Concurrent npm run tsc:w npm run lite" } ...

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

Can we modify the auto-import format from `~/directory/file` to `@/directory/file`?

I have a small issue that's been bugging me. I'm working on a project using Nuxt3 and Vscode. When something is auto-imported, Vscode uses the ~/directory/file prefix instead of the preferred @/directory/file. Is there an easy way to configure Vs ...

Can a new class be created by inheriting from an existing class while also adding a decorator to each field within the class?

In the following code snippet, I am showcasing a class that needs validation. My goal is to create a new class where each field has the @IsOptional() decorator applied. export class CreateCompanyDto { @Length(2, 150) name: string; @IsOptional( ...

I'm puzzled by how my observable seems to be activating on its own without

Sorry if this is a silly question. I am looking at the following code snippet: ngOnInit(): void { let data$ = new Observable((observer: Observer<string>) => { observer.next('message 1'); }); data$.subscribe( ...

Angular, manipulating components through class references instead of creating or destroying them

I am exploring ways to move an angular component, and I understand that it can be achieved through construction and destruction. For example, you can refer to this link: https://stackblitz.com/edit/angular-t3rxb3?file=src%2Fapp%2Fapp.component.html Howeve ...

When trying to reference a vanilla JavaScript file in TypeScript, encountering the issue of the file not being recognized

I have been attempting to import a file into TypeScript that resembles a typical js file intended for use in a script tag. Despite my efforts, I have not found success with various methods. // global.d.ts declare module 'myfile.js' Within the re ...

How can I effectively filter the data returned by consuming an API in JSON through an Angular service?

My Angular 6 project includes a UsersService that is injected into the UsersComponent. Originally, the component displayed mock data in the form of a string array. However, it now consumes JSON data from an API provided by JSONPlaceholder via the UsersSer ...

Are union types strictly enforced?

Is it expected for this to not work as intended? class Animal { } class Person { } type MyUnion = Number | Person; var list: Array<MyUnion> = [ "aaa", 2, new Animal() ]; // Is this supposed to fail? var x: MyUnion = "jjj"; // Should this actually ...

Tips for eliminating unicode characters from Graphql error messages

In my resolver, I have implemented a try and catch block where the catch section is as follows: catch (err: any) { LOG.error("Failed to get location with ID: " + args.id); LOG.error(err); throw new Error(err); ...

What is the best method for implementing Datepicker translations in Angular?

I am looking to incorporate the DatePicker component in Angular, enabling users to select a date that can be translated based on their browser's settings. Any suggestions on how to achieve this? <mat-form-field appearance="fill"> ...

Issue with extending mongoose.Document properly in NodeJS and TypeScript using a custom interface with mongoose

I recently started learning Typescript and tried to follow this guide to help me along: After following the guide, I implemented the relevant code snippets as shown below: import { Document } from "mongoose"; import { IUser } from "../interfaces/user"; ...

How to Eliminate Lower Borders from DataGrid Component in Material UI (mui)

I've been trying to customize the spacing between rows in a MUI Data Grid Component by overriding the default bottom border, but haven't had much success. I've experimented with different approaches such as using a theme override, adding a c ...

Executing multiple instances of the cascading dropdown fill method in Angular 7

I am currently working on an Angular app that includes cascading comboboxes for country and state selection. However, I have noticed that the get states() method in my state.component.ts file is taking a long time to run. What could be causing this issue? ...

transitioning from angular cli version 1.7 to version 12

Looking for a detailed guide on upgrading from version 1.7 to the latest Angular version (12/11)? I currently have an app running on version 1.7 and couldn't find a step-by-step process here: upgrading angular Would it be safe to assume that the upgr ...