Typescript-fel på genererade diskriminerade unioner
Jag försöker skriva en kod som kommer att generera en diskriminerad union för varje medlem av en typ och en funktion som kommer att acceptera ett objekt av den typen och en instans av en av unionens medlemmar.
här är koden jag har hittills
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;
}
Finns också här med TypeScript-lekplatsen
typescript ger mig följande fel
Typen 'string | { name: string; url: string; }' kan inte tilldelas typen 'string & { name: string; url: string; }'. Typen 'string' kan inte tilldelas typen 'string & { name: string; url: string; }'. Typen 'string' kan inte tilldelas typen '{ name: string; url: string; }'.(2322)
Det som är intressant är att när jag hovrar över RickAndMortyCharacterChange-typen ser jag att följande typ är deklarerad:
type RickAndMortyCharacterChange = {
updatedProps: "species";
newValue: string;
} | {
updatedProps: "origin";
newValue: {
name: string;
url: string;
};
}
Vilket verkar korrekt för mig eftersom Typescript förhindrar mig från att skriva detta
const s: RickAndMortyCharacterChange = {
updatedProps: "origin",
newValue: "bonjour"
};
const s2: RickAndMortyCharacterChange = {
updatedProps: "species",
newValue: {
name: "Hello",
url: "Hoi"
}
}
Jag har nyligen använt en mycket mycket liknande metod i ett Angular-projekt och det fungerade perfekt. Jag kan inte förstå varför TypeScript klagar här
Svar #1
Detta är i huvudsak en begränsning i TypeScript-typkontrollalgoritmen, och det underliggande problemet beskrivs i microsoft/TypeScript#30581. När du har ett uttryck av en unionstyp och skriver kod där det uttrycket förekommer flera gånger, analyserar kompilatorn det genom att betrakta varje förekomst av det uttrycket som om det vore oberoende av originalet. Så givet något som
declare const state: RickAndMortyCharacter;
declare const payload: {
updatedProps: "species";
newValue: string;
} | {
updatedProps: "origin";
newValue: {
name: string;
url: string;
};
}
du får detta:
const up = payload.updatedProps;
// const up: "species" | "origin"
const nv = payload.newValue;
// const nv: string | { name: string; url: string; }
Observera att up och nv båda är av unionstyper. Men det har förlorat information som du bryr dig om. Om allt du vet om up är att det är av typen "species" | "origin", och allt du vet om nv är att det är av typen string | { name: string; url: string}, då kan du inte skriva detta längre utan ett fel:
state[up] = nv; // error!
För tänk om up är "origin" men nv är en string? Du vet bara att det är omöjligt eftersom både up och nv kom från samma payload. Men TypeScript analyserar inte identiteten för payload, det tittar på typen. Det finns inget som heter "korrelerade unioner" i TypeScript.
Så uppgiften misslyckas, även om den är 100 % typ säker. Och de kan inte fixa det genom att bara låta kompilatorn analysera state[up]=nv flera gånger, utan att antingen förstöra kompilatorns prestanda massivt eller göra kompilatorn opålitlig. Se denna Twitter-tråd av TS Team:s utvecklingsledare.
Som tur är finns det ett sätt att refaktorera koden så att kompilatorn faktiskt följer logiken. Det beskrivs på microsoft/TypeScript#47109. Tillvägagångssättet är att ersätta unioner med generiska typer som sträcker sig över nyckelliknande typer, och sedan utföra generiska indexeringar i lämpliga mappade typer. Dessa indexeringar i mappade typer kan ske inuti en enda distributiv objekttyp (som myntats i det GitHub-ärendet).
Din RickAndMortyCharacterChange är i princip en sådan typ, men du kan ändra den för att göra den generisk (och fortfarande som standard använda hela unionen):
type RickAndMortyCharacterChange<K extends keyof RickAndMortyCharacter
= keyof RickAndMortyCharacter> = {
[P in K]: {
updatedProps: P;
newValue: RickAndMortyCharacter[P];
};
}[K];
Och nu kan applyPropertyChange() göras generisk i K begränsad till keyof RickAndMortyCharacter. Du kommer att skicka in ett värde av typen RickAndMortyCharacterChange<K>, och plötsligt fungerar allt:
function applyPropertyChange<K extends keyof RickAndMortyCharacter>(
state: RickAndMortyCharacter,
payload: RickAndMortyCharacterChange<K>
) {
state[payload.updatedProps] = payload.newValue; // okay
return state;
}
Om du delar upp det i up och nv kommer du att förstå varför:
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;
}
Nu är up av typen K och nv är av typen RickAndMortyCharacter[K]. De är båda generiska. Och tilldelningen ses som att tilldela en RickAndMortyCharacter[K] på höger sida till en RickAndMortyCharacter[K] på vänster sida. Eftersom dessa typer är identiska generiska fungerar allt som önskat.
Lekplatslänk till kod
Svar #2
Bara för att lägga till på jcalz fantastiska svar, du kan skapa en verktygstyp så att du enkelt kan tillämpa detta mönster på vilken objekttyp som helst:
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;
}
Användning
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
