inferența tipului - Cum să restrângi uniunile discriminate TypeScript bazate pe discriminatori parțial cunoscuți?
Lucrez la un hook React pentru abstractizarea stării pentru diverse funcționalități care împart anumite argumente de funcție, dar unde anumite argumente specifice funcționalității ar trebui să fie obligatorii/interzise în funcție de funcționalitățile activate. Am funcția hook care acceptă un argument care este un obiect cu un tip de uniune discriminată bazat pe proprietăți booleene de tip flag pentru funcționalități, și returnează un obiect care include condiționat unele proprietăți în funcție de funcționalitățile activate:
type CustomHookArgs = (
| ({ isFeatureOneEnabled: true } & FeatureOneArgs)
| { isFeatureOneEnabled?: false }
) &
(
| ({ isFeatureTwoEnabled: true } & FeatureTwoArgs)
| { isFeatureTwoEnabled?: false }
) & {
someSharedArgsHere: string;
};
const useCustomHook = (args: CustomHookArgs) => {
const { isFeatureOneEnabled, isFeatureTwoEnabled } = args;
// Do some stuff
return {
...args,
someSharedThings: "here",
...(isFeatureOneEnabled && { someFeatureOneThings: "here" }),
...(isFeatureTwoEnabled && { someFeatureTwoThings: "here" }),
};
};
Dacă apelez useCustomHook și încep să tastez proprietățile obiectului CustomHookArgs, IntelliSense din VS Code va permite/completa automat proprietățile uniunii generice (încă nefiltrate):
{ isFeatureOneEnabled?: boolean; isFeatureTwoEnabled?: boolean; someSharedArgsHere: string }
Proprietățile din FeatureOneArgs și FeatureTwoArgs sunt considerate a nu face parte din tipul CustomHookArgs în acest context și ar trebui să genereze o eroare de tip dacă le includ (ceea ce funcționează doar uneori, dar asta e o întrebare pentru altă dată). Dacă omit valorile booleene pentru caracteristici sau le transmit ca false, obiectul returnat de useCustomHook este corect dedus ca neincluzând proprietățile someFeatureOneThings sau someFeatureTwoThings (sau, după cum descopăr acum, le deduce ca string | undefined în mediul TS playground. Asta nu este ceea ce văd în mediul meu dintr-un motiv oarecare).
Dacă apelez useCustomHook cu featureOneEnabled: true, obiectul args se restrânge corect și TS mă obligă să transmit proprietățile necesare din FeatureOneArgs, dar tot nu pe cele din FeatureTwoArgs. Grozav, de până acum totul bine.
// No errors, as expected
useCustomHook({
someSharedArgsHere: "hello"
});
// Error, as expected (someRequiredFeatureOneArg is required)
useCustomHook({
someSharedArgsHere: "hello"
isFeatureOneEnabled: true,
});
// No errors, as expected
useCustomHook({
someSharedArgsHere: "hello",
isFeatureOneEnabled: true,
someRequiredFeatureOneArg: "here",
});
Dacă încerc să iau obiectul returnat de hook și să referențiez proprietăți pe el, m-aș aștepta să pot accesa someFeatureOneThings dacă am trecut isFeatureOneEnabled: true în hook. Dar iată problema mea: Valoarea returnată de hook nu își deduce corect tipul din tipul restrâns al CustomHookArgs bazat pe valorile transmise în useCustomHook. someFeatureOneThings încă nu există în tipul acelui obiect (în mediul meu... în playground-ul TS este încă string | undefined). M-aș aștepta să fie acolo cu tipul string.
const hookReturnObj = useCustomHook({
someSharedArgsHere: "hello",
isFeatureOneEnabled: true,
someRequiredFeatureOneArg: "hey!",
});
// No problem here
console.log(hookReturnObj.someSharedThings);
// Error: Property 'someFeatureOneThings' does not exist on type...
console.log(hookReturnObj.someFeatureOneThings);
Dacă adaug o condiție în jurul referinței mele la proprietatea specifică caracteristicii, aceasta se restrânge corect în acel bloc:
if (hookReturnObj.isFeatureOneEnabled) {
// No more error here
console.log(hookReturnObj.someFeatureOneThings);
}
(Ok, iată o altă diferență în playground-ul TS — aici nu se restrânge deloc, someFeatureOneThings este încă string | undefined).
Dar deja i-am spus lui TS că isFeatureOneEnabled era adevărat când am apelat useCustomHook. De ce trebuie să îl restrâng din nou?
Mă înșel în legătură cu comportamentul așteptat al unei uniuni discriminante aici? M-aș aștepta ca lăsând tipul de returnare al useCustomHook să fie inferat din instrucțiunea sa return să facă ca proprietatea someFeatureOneThings să existe pe acel tip fără nicio condiție dacă aș trece isFeatureOneEnabled: true.
Ca soluție temporară, există poate o modalitate prin care să pot restrânge explicit tipul folosind un tip utilitar personalizat? Ceva de genul acesta (nu este cod real):
type ReturnObjUnion = ReturnType<typeof useCustomHook>;
type ReturnObjWithFeatureOneEnabled = ReturnType<typeof useCustomHook({ isFeatureOneEnabled: true })>;
console.log((hookReturnObj as ReturnObjWithFeatureOneEnabled).someFeatureOneThings);
Totuși, asta nu e mult mai bine decât a trebui să adaugi o condiție.
Un lucru pe care l-am încercat a fost să adaug un tip de returnare explicit și să folosesc satisfies (nu a funcționat):
type CustomHookReturnObj = {
someSharedThings: string;
} & (
| ({ isFeatureOneEnabled: true } & { someFeatureOneThings: string })
| { isFeatureOneEnabled?: false }
) &
(
| ({ isFeatureTwoEnabled: true } & { someFeatureTwoThings: string })
| { isFeatureTwoEnabled?: false }
);
const useCustomHook = (args: CustomHookArgs) => {
const { isFeatureOneEnabled, isFeatureTwoEnabled } = args;
// Do some stuff
return {
...args,
someSharedThings: "here",
(...(isFeatureOneEnabled && { someFeatureOneThings: "here" })),
(...(isFeatureTwoEnabled && { someFeatureTwoThings: "here" })),
} satisfies CustomHookReturnObj;
};
Orice ajutor ar fi foarte apreciat. Iată un link către un playground TS unde am încercat să reproduc acest lucru, deși observ rezultate diferite acolo față de proiectul meu (așa cum am menționat mai sus).
Răspuns #1
Funcția ta useCustomHook() este implementată ca un singur bloc de cod care depinde de un parametru args de tipul uniune CustomHookArgs. Dar compilatorul poate analiza acel bloc de cod o singură dată. Nu „distribuie” analiza sa între membrii uniunii CustomHookArgs. Așa că pierde tot felul de corelații, iar uniunea returnată este mai largă decât vrei tu, incluzând termeni care de fapt nu vor fi posibili. Obții un fel de amestec indiscriminat de cazuri. Nu există nicio modalitate de a cere compilatorului să analizeze un singur bloc în acest mod (vezi microsoft/TypeScript#25051 pentru o cerere de caracteristică refuzată). Dacă vrei ca compilatorul să țină corect cont de diferitele posibilități, trebuie să scrii efectiv un bloc de cod pentru fiecare caz, astfel:
const useCustomHook = (args: CustomHookArgs) => {
const { isFeatureOneEnabled, isFeatureTwoEnabled } = args;
if (isFeatureOneEnabled && isFeatureTwoEnabled) {
return {
...args,
someSharedThings: "here",
someFeatureOneThings: "here",
someFeatureTwoThings: "here",
}
}
if (isFeatureOneEnabled && !isFeatureTwoEnabled) {
return {
...args,
someSharedThings: "here",
someFeatureOneThings: "here",
}
}
if (!isFeatureOneEnabled && isFeatureTwoEnabled) {
return {
...args,
someSharedThings: "here",
someFeatureTwoThings: "here"
}
}
return {
...args,
someSharedThings: "here",
}
}
Asta este destul de redundant, dar cel puțin ieșirea nu conține membri de uniune imposibili. În schimb, dacă vrei să scrii o singură instrucțiune return, probabil va trebui să folosești o afirmație de tip sau ceva asemănător pentru a convinge compilatorul că valoarea corespunde tipului de ieșire dorit.
În plus, te aștepți ca tipul de întoarcere al useCustomHook() să depindă de modul în care îl apelezi. Dar acest lucru nu se întâmplă automat cu intrările de tip uniune. Singurele moduri de a face ca o funcție să returneze tipuri diferite în funcție de tipurile de intrare sunt fie să o supraincarci, fie să o faci generică.
Și din nou, este puțin probabil ca compilatorul să poată urma logica implementării tale în ambele cazuri. Supradefinirile vor permite, în general, implementări nesigure fără reclamații, iar genericele vor fi de obicei extinse prematur la constrângeri și vei primi erori pentru implementări sigure. Oricum ar fi, probabil va trebui să îți asumi o parte din responsabilitatea pentru siguranța tipurilor, deoarece fie compilatorul va permite prea multe (așa că trebuie să fii atent), fie prea puține (așa că trebuie să folosești o aserțiune de tip, ceea ce necesită și ea precauție).
Depinde de tine în ce mod dorești să procedezi. O posibilitate este să faci funcția generică, să găsești o relație de tip între tipurile de intrare și ieșire și să afirmi pur și simplu că implementarea ta se comportă astfel. Poate așa:
type CustomHookArgs<F1 extends boolean, F2 extends boolean> =
{ isFeatureOneEnabled?: F1, isFeatureTwoEnabled?: F2, someSharedArgsHere: string } &
{ true: { someRequiredFeatureOneArg: string }, false: {} }[`${F1}`] &
{ true: { someRequiredFeatureTwoArg: string }, false: {} }[`${F2}`];
type CustomHookReturn<F1 extends boolean, F2 extends boolean> =
{ someSharedThings: string } &
{
true: { isFeatureOneEnabled: true, someFeatureOneThings: string },
false: { isFeatureOneEnabled?: false }
}[`${F1}`] &
{
true: { isFeatureTwoEnabled: true, someFeatureTwoThings: string },
false: { isFeatureTwoEnabled?: false }
}[`${F2}`];
const useCustomHook = <F1 extends boolean = false, F2 extends boolean = false>(
args: CustomHookArgs<F1, F2>) => {
const { isFeatureOneEnabled, isFeatureTwoEnabled } = args;
return {
...args,
someSharedThings: "here",
...(isFeatureOneEnabled && { someFeatureOneThings: "here" }),
...(isFeatureTwoEnabled && { someFeatureTwoThings: "here" }),
} as CustomHookReturn<boolean, boolean> as CustomHookReturn<F1, F2>;
};
În esență, funcția este generică în tipurile boolean ale proprietăților isFeatureOneEnabled și isFeatureTwoEnabled, F1 și F2, respectiv. Dacă omiți proprietățile (și compilatorul nu reușește să deducă genericul din acestea), atunci ele au valoarea implicită false. Intrarea este de tip CustomHookArgs<F1, F2> iar ieșirea este de tip CustomHookReturn<F1, F2>. Atât intrarea, cât și ieșirea comută practic pe F1 și F2 folosind tipuri de acces indexat care folosesc tipul literal șirificat al șablonului F1 și F2 ca index. Acesta este un fel de truc pentru a evita tipurile condiționale. Dacă vrei, ai putea înlocui {true: X, false: Y}[`$ {F}`] cu F extends true ? X : Y.
Oricum, să ne asigurăm doar că funcționează așa cum dorim din partea apelului:
const obj1 = useCustomHook({
someSharedArgsHere: "hello"
}); // okay
// const obj1: CHR<false, false>
obj1.someFeatureOneThings; // error
// Property 'someFeatureOneThings' does not exist on type 'CustomHookReturn<false, false>'
const obj2 = useCustomHook({
someSharedArgsHere: "hello",
isFeatureOneEnabled: true
}); // error!
// Property 'someRequiredFeatureOneArg' is missing.
const obj3 = useCustomHook({
someSharedArgsHere: "hello",
isFeatureOneEnabled: true,
someRequiredFeatureOneArg: "here"
}); // okay
// const obj3: CHR<true, false>
obj3.someFeatureOneThings; // string
Arată bine. Din nou, s-ar putea să vrei să folosești o abordare diferită, dar vei fi supus acelorași probleme generale descrise mai sus.
Link de teren de joacă către cod
