Is there a way to convert discriminated unions to specific types (such as classes) using a factory function in Typescript?

I've been experimenting with a code snippet similar to the one below:

type Node =
    | { type: 'Group'; children: Node[] }
    | { type: 'Obj'; payload: number };

class Group {
    readonly type = 'Group';
    constructor(public n: Node[]) { }
}

class Obj {
    readonly type = 'Obj';
    constructor(public p: number) { }
}

type NodeMappings =
    { Group: Group; Obj: Obj };

function createNode<T extends Node>(node: T): NodeMappings[T['type']] {
    switch (node.type) {
        case 'Group':
            return new Group(node.children); // error!
        case 'Obj':
            return new Obj(node.payload); // error!
    }
}

How can I modify the given code to make it compatible with different types for the mapping in the createNode function (currently implemented using a switch ... case)? Is this potentially an issue in Typescript?

Answer №1

The connection between the Node and NodeMappings cannot be automatically understood by the compiler to ensure that createNode() is correctly implemented.

To clarify this relationship for the compiler, you must explicitly define it by following the guidelines outlined in microsoft/TypeScript#47109. You need to establish a "base" type that resembles a key-value mapping from each type property to the other relevant members of Node:

type NodeMap = { [N in Node as N['type']]:
    { [P in keyof N as P extends "type" ? never : P]: N[P] }
}

this can be equated to

// type NodeMap = { 
//   Group: { children: Node[]; }; 
//   Obj: { payload: number; };
// }

Subsequently, all operations should be framed in relation to that base type and accessed through indexes using either generic keys, or generic indexes within a mapped type on that type.

We can redefine Node as an indexed mapped type (referred to as a distributive object type in microsoft/TypeScript#47109):

type MyNode<K extends keyof NodeMap = keyof NodeMap> =
    { [P in K]: { type: P } & NodeMap[P] }[K]

and subsequently, createNode() is formulated based on the generic index K and MyNode<K>:

function createNode<K extends keyof NodeMap>(node: MyNode<K>) {
    const m: { [K in keyof NodeMappings]: (n: MyNode<K>) => NodeMappings[K] } = {
        Group(n) { return new Group(n.children) },
        Obj(n) { return new Obj(n.payload) }
    };
    return m[node.type](node); // okay
}

It is important to note that we have moved away from the control-flow based implementation involving switch/case. Merely checking node.type does not alter K, unless changes are made to microsoft/TypeScript#33014. Therefore, instead of relying on this method, we create an object with methods mirroring the names of node.type and accepting the respective node as input. Essentially, we construct an object that maps MyNode<K> to NodeMappings[K].

This approach works effectively; the invocation m[node.type](node) mirrors the behavior of the traditional switch/case structure, but utilizes property lookups to distinguish between cases.

Furthermore, let's ensure that callers still experience seamless functionality:

function test() {
    const n = createNode({ type: 'Obj', payload: 8 });
    //    ^? const n: Obj
}

Everything appears to be functioning as intended. The inference of "Obj" for K results in a return type of Obj, validating the integrity of the operation.

Access the code via Playground link

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

Angular2: Error - trying to access 'this.' which is not defined

I have a function that is designed to retrieve and display the "best player" from an array of objects, which essentially refers to the player with the most likes. The functionality of this function works as intended and displays the desired output. However ...

Using object in Typescript for function overloading - External visibility of implementation signatures for overloads is restricted

Issue How do I correctly expose an overloaded implementation signature? Scenario Expanding on the initial query: interface MyMap<T> { [id: string]: T; } type Options = { asObject?: boolean, other?: Function testing?: number }; function g ...

Guidelines for manipulating SVG elements using the Bobril framework

I'm looking to animate the movement of an SVG circle based on mouse input in bobril. Which lifecycle component method should I utilize for this task? I've experimented with onPointerDown, but it seems to only respond to events within the circle i ...

Using both props.children and a component as a prop in React/Next.js

User.tsx serves as a navigation bar that passes all children components through via {props.children}. I am also attempting to include a breadcrumb component, but I keep encountering the following error message: JSX element type 'props.component&apos ...

Achieving intellisense functionality in TypeScript without the use of classes

Just dipped my toes into TypeScript, attempting to convert this basic JavaScript code to TypeScript. Here is the JavaScript code snippet: Item = {} Item.buy = function (id) {} Item.sell = function (id) {} I prefer not to use classes and would like to ut ...

The inability to access a route with an authentication guard in the app controller is causing the validate function in the local strategy file to not run

While trying to access my login route in the app.controller.ts of my rest api built with Nestjs and Prisma, I encountered a 401 error response. I have been closely following the official documentation provided by Nestjs on authentication (https://docs.nest ...

Unlocking the potential of deeply nested child objects

I have a recursively typed object that I want to retrieve the keys and any child keys of a specific type from. For example, I am looking to extract a union type consisting of: '/another' | '/parent' | '/child' Here is an il ...

How does Typescript overlook such a peculiar inconsistency?

I've come across a peculiar situation where Typescript doesn't seem to differentiate between an object like {} and a generic array []. It accepts the latter as input for a function that is supposed to require an object with {}'s structure. ...

Is it more efficient to have deps.ts per workspace or shared among workspaces?

Currently, I am in the process of setting up my very first monorepo for a Deno-based application. In this monorepo, the workspaces will be referred to as "modules" that the API code can import from, with each module having its own test suite, among other t ...

The issue of losing context when using Papaparse with an Angular 4 function

Check out this block of code httpcsv2Array(event) { var gethttpcsv = Papa.parse('https://docs.google.com/spreadsheets/d/e/yadyada/pub?output=csv', { download: true, header: true, ...

Utilize a service injected through dependency injection within a static constant defined within the component itself

Using Angular, I am trying to utilize a service that has been injected through dependency injection as a value for a static variable. Here is the scenario: Starting with the constructor where the Helper Service is injected: constructor(private _helper ...

When utilizing Immer with an Image, an error occurs stating: "Type 'Element' is not compatible with type 'WritableDraft<Element>'."

My goal is to store and retrieve cached images for future rendering on canvas using the drawImage method. To achieve this, I have created a type definition for the cache: type Cache = { image: Record<string, HTMLImageElement>, }; const initialCac ...

Transferring information between Puppeteer and a Vue JS Component

When my app's data flow starts with a backend API request that triggers a Vue component using puppeteer, is there a way to transfer that data from Backend (express) to the vue component without requiring the Vue component to make an additional backend ...

A layout featuring nested buttons and links within a card element, utilizing the power of Link in NextJs

After extensive searching on S.O., I have been unable to find a solution that works flawlessly. The issue at hand involves a card component in a NextJs application that is encompassed within a <Link> tag. Additionally, there is another <Link> t ...

Can the TypeScript Event class be customized and extended?

Snippet of Code: class CustomEvent extends Event { constructor(name) { super(name); } } var customEvent = new CustomEvent("scroll"); Error Encountered During Runtime: An error occurred: Uncaught TypeError: Failed to construct 'Ev ...

Creating TypeScript utility scripts within an npm package: A Step-by-Step Guide

Details I'm currently working on a project using TypeScript and React. As part of my development process, I want to automate certain tasks like creating new components by generating folders and files automatically. To achieve this, I plan to create u ...

Encapsulating constructor variables in TypeScript classes through private access modifiers and using public getters

Coming from a background in C#, I am used to designing most of my classes to be immutable. I am curious about whether it is considered good practice in TypeScript to use private constructor variables and public getters for accessing data within classes. T ...

The issue with Ionic 2 and Angular 2 is that the http Headers are not getting included in the request

I'm currently working with the latest beta release of Ionic and I've encountered an issue with sending headers along with an HTTP POST request to my API server. The code snippet I used is as follows: ** Ionic version - Beta-8 & Angular version -r ...

What is the unit testing framework for TypeScript/JavaScript that closely resembles the API of JUnit?

I am in the process of transferring a large number of JUnit tests to test TypeScript code on Node.js. While I understand that annotations are still an experimental feature in TypeScript/JavaScript, my goal is to utilize the familiar @Before, @Test, and @Af ...

Retrieve the key value pairs exclusively when utilizing the find method on a JSON array in JavaScript

To extract a value from a JSON array, I am utilizing the find method in this manner: let actualElm = this.initialData.find(elm => { if (elm.identifiant == this.actualId) { return elm.country; } }); An issue with using find is that it returns t ...