Pumkko
Pumkko

Reputation: 1593

Typescript error on generated discriminated unions

I'm trying to write a code that will generate a discriminated union for each member of a type and a function that will accept an object of that type and an instance of one of the union member.

here's the code I have 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;
}


Also available here with the typescript playground

typescript gives me the following error

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)

What is interesting is when i hover over the RickAndMortyCharacterChange type I see that the following type is declared:

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

Which seems to be right to me since Typescript prevents me from writing this

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

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

I have used a very very similar method in an Angular project recently and it worked perfectly I can not understand why typescript complains here

Upvotes: 2

Views: 138

Answers (2)

Chris Hamilton
Chris Hamilton

Reputation: 10954

Just to add on to jcalz awesome answer, you can make a utility type so you can easily apply 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;
}

Usage

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

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

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

applyPropertyChange(character, change); // ok

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

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

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

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

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

Upvotes: 1

jcalz
jcalz

Reputation: 328196

This is essentially a limitation of the TypeScript type checking algorithm, and the underlying issue is described in microsoft/TypeScript#30581. When you have an expression of a union type and write code where that expression appears multiple times, the compiler analyzes it by treating each utterance of that expression as if it were independent of the original. So given something like

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

you get this:

const up = payload.updatedProps;
// const up: "species" | "origin"
const nv = payload.newValue;
// const nv: string | { name: string; url: string; }

Note that up and nv are both of union types. But that has lost information you care about. If all you know about up is that it's of type "species" | "origin", and all you know about nv is that it's of type string | { name: string; url: string}, then you can't write this anymore without an error:

state[up] = nv; // error!

Because what if up is "origin" but nv is a string? You only know that's impossible because both up and nv came from the same payload. But TypeScript doesn't analyze the identity of payload, it's looking at the type. There's no such thing as "correlated unions" in TypeScript.

So the assignment fails, even though it's 100% type safe. And they can't fix it by just making the compiler analyze state[up] = nv multiple times, without either massively destroying compiler performance or making the compiler unpredictable. See this twitter thread by the TS Team dev lead.


Luckily, there is a way to refactor the code so that the compiler does follow the logic. It's described at microsoft/TypeScript#47109. The approach is to replace unions with generics that range over keylike types, and then perform generic indexes into appropriate mapped types. These indexes-into-mapped types can happen inside a single distributive object type (as coined in that GitHub issue).

Your RickAndMortyCharacterChange is essentially such a type, but you can change it to make it generic (and still default to the full union):

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

And now applyPropertyChange() can be made generic in K constrained to keyof RickAndMortyCharacter. You will pass in a value of type RickAndMortyCharacterChange<K>, and suddenly everything works:

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

If you break that into up and nv you'll see why:

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

Now up is of type K and nv is of type RickAndMortyCharacter[K]. They are both generic. And the assignment is seen as assigning a RickAndMortyCharacter[K] on the right to a RickAndMortyCharacter[K] on the left. Since these types are identical generics, everything works as desired.


Playground link to code

Upvotes: 2

Related Questions