Error in Typescript when working with discriminated unions

I'm currently working on creating a code that will generate a discriminated union for each member of a type and a function that allows for accepting an object of that type along with an instance of one of the union members.

Here's the progress I've made so far:

interface RickAndMortyCharacter {
  species: string;
  origin: {
    name: string;
    url: string;
  };

}

type RickAndMortyCharacterChange = {
  [Prop in keyof RickAndMortyCharacter]: {
    updatedProps: Prop;
    newValue: RickAndMortyCharacter[Prop];
  };
}[keyof RickAndMortyCharacter];


function applyPropertyChange(
  state: RickAndMortyCharacter,
  payload: RickAndMortyCharacterChange
) {
  // Error on this line
  state[payload.updatedProps] = payload.newValue;

  return state;
}


You can also try it out with TypeScript on the playground

When using TypeScript, I encountered the following error message:

Type 'string | { name: string; url: string; }' is not assignable to type 'string & { name: string; url: string; }'. Type 'string' is not assignable to type 'string & { name: string; url: string; }'. Type 'string' is not assignable to type '{ name: string; url: string; }'.(2322)

Interestingly, upon hovering over the RickAndMortyCharacterChange type, I noticed that the declared type was as follows:

type RickAndMortyCharacterChange = {
    updatedProps: "species";
    newValue: string;
} | {
    updatedProps: "origin";
    newValue: {
        name: string;
        url: string;
    };
}

This declaration seems accurate to me, especially considering that TypeScript prevents me from writing these examples:

const s: RickAndMortyCharacterChange = {
  updatedProps: "origin",
  newValue: "bonjour"
};

const s2: RickAndMortyCharacterChange = {
  updatedProps: "species",
  newValue: {
    name: "Hello",
    url: "Hoi"
  }
}

I have recently used a similar method in an Angular project which worked flawlessly. I am puzzled as to why TypeScript is flagging an issue here.

Answer №1

The TypeScript type checking algorithm faces a limitation concerning union types when the same expression is repeated multiple times in the code. This issue is detailed in microsoft/TypeScript#30581. For example:

declare const state: RickAndMortyCharacter;
declare const payload: {
    updatedProps: "species";
    newValue: string;
} | {
    updatedProps: "origin";
    newValue: {
        name: string;
        url: string;
    };
}

In this scenario, both up and nv end up defining of union types, losing essential information. This leads to errors like:

state[up] = nv; // error!

The root cause here is that the compiler treats each occurrence of an expression independently without considering their correlation from the same origin. The inability to handle "correlated unions" presents challenges for assignments. However, there is a solution presented in microsoft/TypeScript#47109, utilizing generics and mapped types in TypeScript.

This approach involves creating a generic type RickAndMortyCharacterChange which allows for more precise typing and assignment logic:

type RickAndMortyCharacterChange<K extends keyof RickAndMortyCharacter
  = keyof RickAndMortyCharacter> = {
    [P in K]: {
      updatedProps: P;
      newValue: RickAndMortyCharacter[P];
    };
  }[K];

By leveraging generics in functions like applyPropertyChange(), the compiler can now correctly interpret the assignment logic with the necessary type safety:

function applyPropertyChange<K extends keyof RickAndMortyCharacter>(
  state: RickAndMortyCharacter,
  payload: RickAndMortyCharacterChange<K>
) {
  state[payload.updatedProps] = payload.newValue; // okay
  return state;
}

Through this refactored approach, TypeScript can maintain the intended logic in handling properties and values within a consistent context.

Answer №2

Expanding on jcalz's excellent answer, consider creating a utility type for easy application of this pattern to any object type:

type PropertyChange<T extends Object, K extends keyof T = keyof T> = {
  [P in K]: {
    updatedProps: P;
    newValue: T[P];
  };
}[K];

function applyPropertyChange<T extends Object, K extends keyof T = keyof T>(
  state: T,
  payload: PropertyChange<T, K>
) {
  state[payload.updatedProps] = payload.newValue;
  return state;
}

Example Usage

const character: RickAndMortyCharacter = {
  species: 'initial species',
  origin: { name: '', url: '' },
};

const change: PropertyChange<RickAndMortyCharacter> = {
  updatedProps: 'species',
  newValue: 'new species',
}; // acceptable

const change2: PropertyChange<RickAndMortyCharacter> = {
  updatedProps: 'origin',
  newValue: '',
}; // error

applyPropertyChange(character, change); // valid

applyPropertyChange(
  character, 
  { updatedProps: 'origin', newValue: '' }
); // error

applyPropertyChange(
  character, 
  { updatedProps: 'species', newValue: '' }
); // valid

applyPropertyChange(
  { prop1: 'string' },
  { updatedProps: 'prop1', newValue: 'new string' }
); // valid

applyPropertyChange(
  { prop1: 1 },
  { updatedProps: 'prop1', newValue: 'new string' }
); // error

applyPropertyChange<{ prop1: number }>(
  { prop1: 'string' },
  { updatedProps: 'prop1', newValue: 'new string' }
); // error

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

Can you explain the significance of the "@" symbol in the `import from '@/some/path'` statement?

When I use IntelliJ IDEA to develop a web application in TypeScript, the autocomplete feature suggests imports from other files within my project like this: import {Symbol} from '@/components/Symbol'; I am curious about the significance of the @ ...

Limit the values in the array to only match the keys defined in the interface

I'm trying to restrict an array's elements to specific keys of an interface: interface Foo { bar: string; baz: number; foo: string; } type SelectedKeysArray<T, K extends keyof T> = Pick<T, K>[]; const selectedKeys: SelectedKey ...

How to retrieve a value from a Map that contains either zero or one entry?

I currently have a Map object with the possibility of containing zero or one entry. Here's an example: class Task { constructor( public id:string, public name:string, public description: string ) {} } const task1 = new Task('1', ...

Unexpected token error in TypeScript: Syntax mistake spotted in code

Being new to Angular, I understand that mastering TypeScript is crucial for becoming a skilled Angular developer. Therefore, I created this simple program: function loge(messag){ console.log(messag); } var message:string; message = "Hi"; loge(messa ...

Type of JavaScript map object

While exploring TypeScript Corday, I came across the following declaration: books : { [isbn:string]:Book}={}; My interpretation is that this could be defining a map (or dictionary) data type that stores key-value pairs of an ISBN number and its correspon ...

Encountering an error while compiling the Angular 8 app: "expected ':' but got error TS1005"

As I work on developing an Angular 8 application through a tutorial, I find myself facing a challenge in my header component. Specifically, I am looking to display the email address of the currently logged-in user within this component. The code snippet fr ...

How can methods from another class be accessed in a TypeScript constructor?

I need to access a method from UserModel within the constructor of my UserLogic class. How can I achieve this? import { UserModel, ItUser } from '../../models/user.model'; export class UserLogic { public user: ItUser; constructor() { ...

The prototype's function doesn't pause for anything, carrying out its duty in a continuous cycle

I have been attempting to enhance the prototype of an object by adding an asynchronous function to be called later on. Here is my approach: const ContractObject = Object; ContractObject.prototype['getBalance'] = async function(userId: number ...

No bugs appeared in Next.js

I am currently in the process of migrating a large create-react-app project to NextJS. To do this, I started a new Next project using create-next-app and am transferring all the files over manually. The main part of my page requires client-side rendering f ...

Can you identify a specific portion within an array?

Apologies for the poorly titled post; summarizing my query into one sentence was challenging. I'm including the current code I have, as I believe it should be easy to understand. // Constants that define columns const columns = ["a", " ...

Updating an object property within an array in Angular Typescript does not reflect changes in the view

I am currently delving into Typescript and Angular, and I have encountered an issue where my view does not update when I try to modify a value in an array that is assigned to an object I defined. I have a feeling that it might be related to the context b ...

There was an issue thrown during the afterAll function: Unable to access properties of undefined

Recently, I upgraded my Angular project from version 15 to 15.1 and encountered an error while running tests. To replicate the issue, I created a new Angular 15.1 project using the CLI and generated a service with similar semantics to the one causing probl ...

A beginner's guide to integrating with oauth1 service using Angular

When trying to authenticate the user of my web app with Trello, which uses oauth1 authentication, I noticed that the oauth library recommended in their demo project does not have typings. What would be the most suitable alternative for authenticating my ...

Encountering an issue when attempting to upgrade to Angular 9: Error arising in metadata creation for exported symbol

I am currently in the process of upgrading my Angular 8 application to Angular 9. When running the migration command, I encountered the following issue: Undecorated classes with DI migration. As of Angular 9, it is no longer supported to use Angular ...

PhpStorm IDE does not recognize Cypress custom commands, although they function properly in the test runner

Utilizing JavaScript files within our Cypress testing is a common practice. Within the commands.js file, I have developed a custom command: Cypress.Commands.add('selectDropdown', (dropdown) => { cy.get('#' + dropdown).click(); } ...

Is there a way to verify if the object's ID within an array matches?

I am looking to compare the ID of an object with all IDs of the objects in an array. There is a button that allows me to add a dish to the orders array. If the dish does not already exist in the array, it gets added. However, if the dish already exists, I ...

Obtaining the component instance ('this') from a template

Imagine we are in a situation where we need to connect a child component property to the component instance itself within a template: <child-component parent="???"></child-component1> Is there a solution for this without having to create a sp ...

Retrieving a nested type based on a particular condition while keeping track of its location

Given an object structure like the one below: type IObject = { id: string, path: string, children?: IObject[] } const tree = [ { id: 'obj1' as const, path: 'path1' as const, children: [ { id: &ap ...

Can you please provide the Typescript type of a route map object in hookrouter?

Is there a way to replace the 'any' type in hookrouter? type RouteMap = Record<string, (props?: any) => JSX.Element>; Full Code import { useRoutes, usePath, } from 'hookrouter' //// HOW DO I REPLACE any??? type RouteMap = ...

What could be causing my date variable to reset unexpectedly within my map function?

Currently, I'm utilizing a tutorial to create a custom JavaScript calendar and integrating it into a React project You can find the functional JavaScript version in this jsfiddle import { useState, useRef, useMemo } from 'react' import type ...