TypeScript avansat cu un exemplu real (partea 2)
Publicat: 2020-11-20Este timpul să continuăm cu (și să sperăm să finalizam) tutorialul nostru TypeScript. Dacă ați ratat postările anterioare pe care le-am scris despre TypeScript, iată-le: introducerea noastră inițială în TypeScript și prima parte a acestui tutorial în care explic exemplul JavaScript cu care lucrăm și pașii pe care i-am urmat pentru a-l îmbunătăți parțial .
Astăzi vom termina exemplul nostru completând tot ceea ce mai lipsește. Mai exact, vom vedea mai întâi cum să creăm tipuri care sunt versiuni parțiale ale altor tipuri existente. Vom vedea apoi cum să introducem corect acțiunile unui magazin Redux folosind uniuni de tip și vom discuta despre avantajele oferite de uniunile de tip. Și, în sfârșit, vă voi arăta cum să creați o funcție polimorfă al cărei tip de returnare depinde de argumentele sale.
O scurtă trecere în revistă a ceea ce am făcut până acum...
În prima parte a tutorialului am folosit (o parte din) un magazin Redux pe care l-am luat de la Nelio Content ca exemplu de lucru. Totul a început ca un cod JavaScript simplu, care a trebuit îmbunătățit prin adăugarea unor tipuri concrete care îl fac mai robust și mai inteligibil. Astfel, de exemplu, am definit următoarele tipuri:
type PostId = number; type Day = string; type Post = { id: PostId; title: string; author: string; day: Day; status: string; isSticky: boolean; }; type State = { posts: Dictionary<PostId, Post>; days: Dictionary<Day, PostId[]>; }; care ne-a ajutat să înțelegem, dintr-o privire, tipul de informații cu care lucrează magazinul nostru. În acest caz particular, de exemplu, putem vedea că starea aplicației noastre stochează două lucruri: o listă de posts (pe care le-am indexat prin PostId -ul lor) și o structură numită days care, dată fiind o anumită zi, returnează o listă de identificatorii postului. Putem vedea, de asemenea, atributele (și tipurile lor specifice) pe care le vom găsi într-un obiect Post .
Odată ce aceste tipuri au fost definite, am editat toate funcțiile exemplului nostru pentru a le folosi. Această sarcină simplă a transformat semnăturile funcțiilor opace ale JavaScript:
// Selectors function getPost( state, id ) { ... } function getPostsInDay( state, day ) { ... } // Actions function receiveNewPost( post ) { ... } function updatePost( postId, attributes ) { ... } // Reducer function reducer( state, action ) { ... }la semnături auto-explicative ale funcției TypeScript:
// Selectors function getPost( state: State, id: PostId ): Post | undefined { ... } function getPostsInDay( state: State, day: Day ): PostId[] { ... } // Actions function receiveNewPost( post: Post ): any { ... } function updatePost( postId: PostId, attributes: any ): any { ... } // Reducer function reducer( state: State, action: any ): State { ... } Funcția getPostsInDay este un exemplu foarte bun despre cât de mult va îmbunătăți TypeScript calitatea codului dvs. Dacă te uiți la omologul JavaScript, chiar nu știi ce va returna acea funcție. Sigur, numele său ar putea sugera tipul de rezultat (este o listă de postări, poate?), dar trebuie să te uiți la codul sursă al funcției (și probabil și la acțiunile și reductoarele) pentru a fi sigur (este de fapt o listă de ID-uri de post). Se poate îmbunătăți această situație denumind mai bine lucrurile ( getIdsOfPostsInDay , de exemplu), dar nu există nimic ca tipurile concrete pentru a curăța orice îndoială: PostId[] .
Așadar, acum că sunteți la curent cu starea actuală a lucrurilor, este timpul să trecem mai departe și să remediați tot ce am omis săptămâna trecută. Mai exact, știm că trebuie să introducem atributele attributes ale funcției updatePost și trebuie să definim tipurile pe care le vor avea acțiunile noastre (rețineți că în reducer , atributul de action chiar acum este de tip any ).
Cum să tastați un obiect ale cărui atribute sunt un subset al altui obiect
Să ne încălzim începând cu ceva simplu. Funcția updatePost generează o acțiune care semnalează intenția noastră de a actualiza anumite atribute ale unui ID de postare dat. Iată cum arată:
function updatePost( postId: PostId, attributes: any ): any { return { type: 'UPDATE_POST', postId, attributes, }; }și iată cum este folosită acțiunea de către reductor pentru a actualiza postarea din magazinul nostru:
function reducer( state: State, action: any ): State { // ... switch ( action.type ) { // ... case 'UPDATE_POST': if ( ! state.posts[ action.postId ] ) { return state; } const post = { ...state.posts[ action.postId ], ...action.attributes, }; return { ... }; } // ... }După cum puteți vedea, reductorul caută postarea din magazin și, dacă este acolo, își actualizează atributele suprascriindu-le folosind cele incluse în acțiune.
Dar care sunt exact attributes unei acțiuni? Ei bine, sunt în mod clar ceva care arată similar cu o Post , deoarece ar trebui să suprascrie atributele pe care le putem găsi într-o postare:
type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Post; };dar dacă încercăm să folosim asta vom vedea că nu funcționează:
const post: Post = { id: 1, title: 'Title', author: 'Ruth', day: '2020-10-01', status: 'draft', isSticky: false, }; const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; pentru că nu dorim ca attributes să fie un Post în sine; dorim să fie un subset de atribute Post (adică vrem să specificăm doar acele atribute ale unui obiect Post pe care le vom suprascrie).
Pentru a rezolva această problemă, utilizați doar tipul de utilitar Partial :
type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Partial<Post>; };Si asta e! Sau este?
Atributele de filtrare explicit
Fragmentul anterior este încă defect, deoarece este posibil să obțineți unele erori de rulare pe care compilatorul TypeScript nu le verifică. Iată de ce: acțiunea care semnalează o actualizare post are două argumente, un ID post și setul de atribute pe care vrem să-l actualizăm. Odată ce avem acțiunea pregătită, reductorul este responsabil să suprascrie postarea existentă cu noile valori:
const post = { ...state.posts[ action.postId ], ...action.attributes, }; Și aceasta este tocmai partea defectuoasă din codul nostru; este posibil ca atributul postId al acțiunii să aibă un ID pot x și atributul id din attributes să aibă un ID post diferit y :
const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 2, author: 'Toni', }, }; Aceasta este, evident, o acțiune validă și, prin urmare, TypeScript nu declanșează erori, dar știm că nu ar trebui să fie. Atributul id în attributes (dacă este prezent) și atributul postId ar trebui să aibă aceeași valoare, altfel avem o acțiune incoerentă. Tipul nostru de acțiune este imprecis, deoarece ne permite să definim o situație care ar trebui să fie imposibilă... deci cum putem remedia acest lucru? Destul de ușor: schimbați doar acest tip, astfel încât acest scenariu care ar trebui să fie imposibil să devină de fapt imposibil.
Prima soluție la care m-am gândit este următoarea: eliminați atributul postId din acțiune și adăugați ID-ul în attributes atribute:
type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post>; }; function updatePost( postId: PostId, attributes: Partial<Post> ): UpdatePostAction { return { type: 'UPDATE_POST', attributes: { ...attributes, id: postId, }, }; } Apoi, actualizați reductorul astfel încât să folosească action.attributes.id în loc de action.postId pentru a găsi și a suprascrie postarea existentă.
Din păcate, această soluție nu este ideală, deoarece attributes este o „postare parțială”, vă amintiți? Aceasta înseamnă că, în teorie, atributul id poate fi sau nu în obiectul attributes . Sigur, știm că va fi acolo, pentru că noi suntem cei care generăm acțiunea... dar tipurile noastre sunt încă imprecise. Dacă în viitor cineva modifică funcția updatePost și nu se asigură că attributes include postId , acțiunea rezultată ar fi validă conform TypeScript, dar codul nostru nu ar funcționa:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };Deci, dacă vrem ca TypeScript să ne protejeze, trebuie să fim cât mai precisi posibil atunci când specificăm tipurile și să ne asigurăm că acestea fac imposibile stările imposibile. Având în vedere toate acestea, avem doar două opțiuni disponibile:
- Dacă avem un atribut
postIdîn acțiune (cum am făcut la început), atunci obiectulattributesnu trebuie să conțină un atributid. - Dacă, pe de altă parte, acțiunea nu are un atribut
postId, atunciattributestrebuie să conțină un atributid.
Prima soluție poate fi specificată cu ușurință folosind un alt tip de utilitate, Omit , care ne permite să creăm un nou tip prin eliminarea atributelor dintr-un tip existent:
type UpdatePostAction = { type: 'UPDATE_POST'; postId: PostId, attributes: Partial< Omit<Post, 'id'> >; };care funcționează conform așteptărilor:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 1, author: 'Toni', }, }; Pentru a doua opțiune, trebuie să adăugăm în mod explicit atributul id peste tipul Partial<Post> pe care l-am definit:

type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post> & { id: PostId }; };care, din nou, ne dă rezultatul așteptat:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };Tipuri de uniuni
În secțiunea anterioară, am văzut deja cum să introducem una dintre cele două acțiuni pe care le are magazinul nostru. Să facem același lucru cu a doua acțiune. Știind că receiveNewPost arată astfel:
function receiveNewPost( post: Post ): any { return { type: 'RECEIVE_NEW_POST', post, }; }tipul său poate fi definit după cum urmează:
type ReceiveNewPostAction = { type: 'RECEIVE_NEW_POST'; post: Post; };Ușor, nu?
Acum să aruncăm o privire la reductorul nostru: ia o state și o action (al căror tip nu-l cunoaștem încă) și produce o State nouă:
function reducer( state: State, action: any ): State { ... } Magazinul nostru are două tipuri diferite de acțiuni: UpdatePostAction și ReceiveNewPostAction . Deci, care este tipul de argument al action ? Una sau alta, nu? Când o variabilă poate accepta mai mult de un tip A , B , C și așa mai departe, tipul ei este o uniune de tipuri. Adică, tipul său poate fi A sau B sau C și așa mai departe. Un tip de unire este un tip ale cărui valori pot fi de oricare dintre tipurile specificate în acea unire.
Iată cum tipul nostru de Action poate fi definit ca tip de uniune:
type Action = UpdatePostAction | ReceiveNewPostAction; Fragmentul anterior afirmă pur și simplu că o Action poate fi fie o instanță a tipului UpdatePostAction , fie o instanță a tipului ReceiveNewPostAction .
Dacă acum folosim Action în reductorul nostru:
function reducer( state: State, action: Action ): State { ... }putem vedea cum funcționează fără probleme această nouă versiune a codului nostru, care este bine tastat.
Cum tipurile de uniuni elimină cazurile implicite
„Așteptați o secundă”, ați putea spune, „linkul anterior nu funcționează bine, compilatorul declanșează o eroare!” Într-adevăr, conform TypeScript, reductorul nostru conține cod inaccesibil:
function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... } return state; //Error! Unreachable code }Stai ce? Lasă-mă să explic ce se întâmplă aici...
Tipul de sindicat Action pe care l-am creat este de fapt un tip de sindicat discriminat. Un tip de uniune discriminat este un tip de uniune în care toate tipurile sale au un atribut comun a cărui valoare poate fi folosită pentru a discrimina un tip de celălalt.
În cazul nostru, cele două tipuri de Action au un atribut type ale cărui valori sunt RECEIVE_NEW_POST pentru ReceiveNewPostAction și UPDATE_POST pentru UpdatePostAction . Deoarece știm că o Action este, în mod necesar, o instanță fie a unei acțiuni, fie a celeilalte, cele două ramuri ale switch nostru acoperă toate posibilitățile: fie action.type este RECEIVE_NEW_POST , fie este UPDATE_POST . Prin urmare, return final este redundant și va fi inaccesibil.
Să presupunem, atunci, că eliminăm acea return pentru a remedia această eroare. Am câștigat ceva, în afară de eliminarea codului inutil? Raspunsul este da. Dacă acum adăugăm un nou tip de acțiune în codul nostru:
type Action = | UpdatePostAction | ReceiveNewPostAction | NewFeatureAction; type NewFeatureAction = { type: 'NEW_FEATURE'; // ... }; dintr-o dată, declarația switch din reductorul nostru nu va mai acoperi toate scenariile posibile:
function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... // case NEW_FEATURE is missing... } // return undefined is now implicit } Aceasta înseamnă că reductorul ar putea returna implicit o valoare undefined dacă o invocăm utilizând o acțiune de tip NEW_FEATURE , iar acesta este ceva care nu se potrivește cu semnătura funcției. Din cauza acestei nepotriviri, TypeScript se plânge și ne anunță că lipsește o nouă ramură pentru a face față acestui nou tip de acțiune.
Funcții polimorfe cu tipuri de returnare variabilă
Dacă ați ajuns până aici, felicitări: ați învățat tot ce trebuie să faceți pentru a îmbunătăți codul sursă al aplicațiilor JavaScript folosind TypeScript. Și, drept recompensă, am de gând să vă împărtășesc o „problemă” pe care am întâlnit-o acum câteva zile și soluția ei. De ce? Pentru că TypeScript este o lume complexă și fascinantă și vreau să vă arăt în ce măsură este adevărat.
La începutul acestei aventuri, am văzut că unul dintre selectorii pe care îi avem este getPostsInDay și cum tipul său de returnare este o listă de ID-uri de postare:
function getPostsInDay( state: State, day: Day ): PostId[] { return state.days[ day ] ?? []; }chiar dacă numele sugerează că ar putea returna o listă de postări. De ce am folosit un nume atât de înșelător, vă întrebați? Ei bine, imaginați-vă următorul scenariu: să presupunem că doriți ca această funcție să fie capabilă fie să returneze o listă de ID-uri de postare, fie să returneze o listă de postări reale, în funcție de valoarea unuia dintre argumentele sale. Ceva de genul:
const ids: PostId[] = getPostsInDay( '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( '2020-10-01', 'all' );Putem face asta în TypeScript? Bineînțeles că facem! Altfel, de ce aș aduce asta în discuție? Tot ce trebuie să facem este să definim o funcție polimorfă al cărei rezultat depinde de parametrii de intrare.
Deci, ideea este că vrem două versiuni diferite ale aceleiași funcții. Ar trebui să returneze o listă de PostId uri dacă unul dintre atribute este id -ul string . Celălalt ar trebui să returneze o listă de Post dacă același atribut este string all .
Să le creăm pe amândouă:
function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[] { // ... } function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[] { // ... }Ușor, nu? GRESIT! Acest lucru nu funcționează. Conform TypeScript, avem o „implementare a funcției duplicate”.
Bine, atunci hai să încercăm ceva diferit. Să îmbinăm cele două definiții anterioare într-o singură funcție:
function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' = 'id' ): PostId[] | Post[] { if ( 'id' === mode ) { return state.days[ day ] ?? []; } return []; }Se comportă așa cum vrem noi? Mi-e teama ca nu...
Iată ce ne spune această semnătură a funcției: „ getPostsInDay este o funcție care ia două argumente, o state și un mode ale căror valori pot fi fie id sau all ; tipul său de returnare va fi fie o listă de PostId uri, fie o listă de Post -uri.” Cu alte cuvinte, definiția anterioară a funcției nu specifică nicăieri că există o relație între valoarea dată argumentului mode și tipul de returnare al funcției. Și așa codați astfel:
const state: State = { posts: {}, days: {} }; const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );este valabil și nu se comportă așa cum ne dorim.
OK, ultima încercare. Ce se întâmplă dacă ne amestecăm intuiția inițială, în care descriem semnături de funcții concrete, cu o singură implementare validă?
function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[]; function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[]; function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' ): PostId[] | Post[] { const postIds = state.days[ day ] ?? []; if ( 'id' === mode ) { return postIds; } return postIds .map( ( pid ) => getPost( state, pid ) ) .filter( ( p ): p is Post => !! p ); } Fragmentul anterior are o implementare validă a funcției care funcționează, dar definește două semnături de funcție suplimentare care leagă valori concrete în mode cu tipul de returnare al funcției.
Folosind această abordare, acest cod este valid:
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );iar acesta nu:
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'all' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'id' );Concluzii
În această serie de postări am văzut ce este TypeScript și cum îl putem aplica în proiectele noastre. Tipurile ne ajută să documentăm mai bine codul, oferind context semantic. Mai mult, tipurile adaugă și un strat suplimentar de securitate, deoarece compilatorul TypeScript se ocupă de validarea faptului că codul nostru se potrivește corect, la fel cum fac Lego.
În acest moment aveți deja toate instrumentele necesare pentru a duce calitatea muncii dvs. la nivelul următor. Succes în această nouă aventură!
Imagine prezentată de Mike Kenneally pe Unsplash.
