Reputation: 1593
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
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
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.
Upvotes: 2