Înțelegerea generice TypeScript
Publicat: 2022-03-10În acest articol, vom învăța conceptul de Generic în TypeScript și vom examina modul în care Genericurile pot fi folosite pentru a scrie cod modular, decuplat și reutilizabil. Pe parcurs, vom discuta pe scurt modul în care acestea se potrivesc în modele mai bune de testare, abordări ale gestionării erorilor și separarea accesului la date/domeniu.
Un exemplu din lumea reală
Vreau să intru în lumea genericelor nu explicând ce sunt acestea, ci mai degrabă oferind un exemplu intuitiv de ce sunt utile. Să presupunem că ați fost însărcinat să construiți o listă dinamică bogată în funcții. L-ați putea numi o matrice, un ArrayList
, o List
, un std::vector
sau orice altceva, în funcție de limbajul dvs. Poate că această structură de date trebuie să aibă și sisteme tampon încorporate sau interschimbabile (cum ar fi o opțiune de inserare a tamponului circular). Va fi un înveliș în jurul matricei JavaScript normale, astfel încât să putem lucra cu structura noastră în loc de matrice simple.
Problema imediată pe care o veți întâlni este aceea a constrângerilor impuse de sistemul de tip. Nu puteți, în acest moment, să acceptați orice tip dorit într-o funcție sau metodă într-un mod curat (vom revizui această declarație mai târziu).
Singura soluție evidentă este replicarea structurii noastre de date pentru toate tipurile diferite:
const intList = IntegerList.create(); intList.add(4); const stringList = StringList.create(); stringList.add('hello'); const userList = UserList.create(); userList.add(new User('Jamie'));
Sintaxa .create()
aici ar putea părea arbitrară și, într-adevăr, new SomethingList()
ar fi mai simplu, dar veți vedea de ce vom folosi această metodă statică din fabrică mai târziu. Intern, metoda create
apelează constructorul.
Acest lucru este groaznic. Avem multă logică în cadrul acestei structuri de colecție și o duplicăm în mod flagrant pentru a sprijini diferite cazuri de utilizare, încălcând complet Principiul DRY în acest proces. Când decidem să ne schimbăm implementarea, va trebui să propagam/reflectăm manual acele modificări în toate structurile și tipurile pe care le acceptăm, inclusiv tipurile definite de utilizator, ca în ultimul exemplu de mai sus. Să presupunem că structura colecției în sine are 100 de linii - ar fi un coșmar să menținem mai multe implementări diferite în care singura diferență între ele este tipul.
O soluție imediată care vă poate veni în minte, mai ales dacă aveți o mentalitate POO, este să luați în considerare un „supertip” rădăcină, dacă doriți. În C#, de exemplu, constă un tip după numele object
, iar object
este un alias pentru clasa System.Object
. În sistemul de tip C#, toate tipurile, fie ele predefinite sau definite de utilizator și fie ele tipuri de referință sau tipuri de valori, moștenesc direct sau indirect din System.Object
. Aceasta înseamnă că orice valoare poate fi atribuită unei variabile de tip object
(fără a intra în semantica stivă/heap și box/unboxing).
În acest caz, problema noastră pare rezolvată. Putem folosi doar un tip ca any
și care ne va permite să stocăm orice dorim în colecția noastră fără a fi nevoie să duplicăm structura și, într-adevăr, este foarte adevărat:
const intList = AnyList.create(); intList.add(4); const stringList = AnyList.create(); stringList.add('hello'); const userList = AnyList.create(); userList.add(new User('Jamie'));
Să ne uităm la implementarea efectivă a listei noastre folosind any
:
class AnyList { private values: any[] = []; private constructor (values: any[]) { this.values = values; // Some more construction work. } public add(value: any): void { this.values.push(value); } public where(predicate: (value: any) => boolean): AnyList { return AnyList.from(this.values.filter(predicate)); } public select(selector: (value: any) => any): AnyList { return AnyList.from(this.values.map(selector)); } public toArray(): any[] { return this.values; } public static from(values: any[]): AnyList { // Perhaps we perform some logic here. // ... return new AnyList(values); } public static create(values?: any[]): AnyList { return new AnyList(values ?? []); } // Other collection functions. // ... }
Toate metodele sunt relativ simple, dar vom începe cu constructorul. Vizibilitatea sa este privată, deoarece vom presupune că lista noastră este complexă și dorim să interzicem construcția arbitrară. De asemenea, s-ar putea dori să realizăm logica înainte de construcție, așa că din aceste motive și pentru a păstra constructorul pur, delegăm aceste preocupări metodelor statice din fabrică/helper, ceea ce este considerat o bună practică.
Sunt furnizate metodele statice from
și create
. Metoda from
acceptă o serie de valori, efectuează o logică personalizată și apoi le folosește pentru a construi lista. Metoda create
static ia o matrice opțională de valori pentru în cazul în care dorim să însămânăm lista noastră cu date inițiale. „Operatorul de coalescere nul” ( ??
) este folosit pentru a construi lista cu o matrice goală în cazul în care nu este furnizată una. Dacă partea stângă a operandului este null
sau undefined
, vom reveni în partea dreaptă, deoarece în acest caz, values
sunt opționale și, prin urmare, pot fi undefined
. Puteți afla mai multe despre coalescerea nulă la pagina de documentație TypeScript relevantă.
Am adăugat și o metodă select
și where
. Aceste metode doar împachetează map
și, respectiv, filter
JavaScript. select
ne permite să proiectăm o matrice de elemente într-o nouă formă bazată pe funcția de selectare furnizată și where
ne permite să filtram anumite elemente pe baza funcției predicate furnizate. Metoda toArray
pur și simplu convertește lista într-o matrice, returnând referința matricei pe care o deținem intern.
În cele din urmă, să presupunem că clasa User
conține o metodă getName
care returnează un nume și, de asemenea, acceptă un nume ca primul și singurul argument de construcție.
Notă: Unii cititori vor recunoașteWhere
șiSelect
din LINQ-ul C#, dar rețineți că încerc să păstrez acest lucru simplu, astfel încât nu sunt îngrijorat de lene sau execuție amânată. Acestea sunt optimizări care ar putea și ar trebui făcute în viața reală.
În plus, ca o notă interesantă, vreau să discut despre sensul „predicat”. În matematică discretă și logică propozițională, avem conceptul de „propoziție”. O propoziție este o afirmație care poate fi considerată adevărată sau falsă, cum ar fi „patru este divizibil cu doi”. Un „predicat” este o propoziție care conține una sau mai multe variabile, prin urmare veridicitatea propoziției depinde de aceea a acelor variabile. Vă puteți gândi la asta ca la o funcție, cum ar fiP(x) = x is divisible by two
, pentru că trebuie să cunoaștem valoarea luix
pentru a determina dacă afirmația este adevărată sau falsă. Puteți afla mai multe despre logica predicaților aici.
Există câteva probleme care vor apărea în urma utilizării any
. Compilatorul TypeScript nu știe nimic despre elementele din lista/matricea internă, prin urmare nu va oferi niciun ajutor în interiorul where
sau select
sau când adăugați elemente:
// Providing seed data. const userList = AnyList.create([new User('Jamie')]); // This is fine and expected. userList.add(new User('Tom')); userList.add(new User('Alice')); // This is an acceptable input to the TS Compiler, // but it's not what we want. We'll definitely // be surprised later to find strings in a list // of users. userList.add('Hello, World!'); // Also acceptable. We have a large tuple // at this point rather than a homogeneous array. userList.add(0); // This compiles just fine despite the spelling mistake (extra 's'): // The type of `users` is any. const users = userList.where(user => user.getNames() === 'Jamie'); // Property `ssn` doesn't even exist on a `user`, yet it compiles. users.toArray()[0].ssn = '000000000'; // `usersWithId` is, again, just `any`. const usersWithId = userList.select(user => ({ id: newUuid(), name: user.getName() })); // Oops, it's "id" not "ID", but TS doesn't help us. // We compile just fine. console.log(usersWithId.toArray()[0].ID);
Deoarece TypeScript știe doar că tipul tuturor elementelor matricei este any
, nu ne poate ajuta la compilare cu proprietățile inexistente sau cu funcția getNames
care nici măcar nu există, astfel că acest cod va duce la mai multe erori de rulare neașteptate. .
Sincer să fiu, lucrurile încep să arate destul de sumbru. Am încercat să implementăm structura noastră de date pentru fiecare tip de beton pe care doream să-l susținem, dar ne-am dat seama rapid că nu era în niciun fel menținut. Apoi, am crezut că ajungem undeva folosind any
, ceea ce este analog cu dependența de un supertip rădăcină dintr-un lanț de moștenire din care derivă toate tipurile, dar am ajuns la concluzia că pierdem siguranța tipului cu acea metodă. Care este soluția, atunci?
Se pare că, la începutul articolului, am mințit (un fel):
„Nu puteți, în acest moment, să acceptați orice tip dorit într-o funcție sau metodă într-un mod curat.”
De fapt, puteți, și aici intervin Generics. Observați că am spus „în acest moment”, pentru că presupuneam că nu știam despre Generic la acel moment al articolului.
Voi începe prin a arăta implementarea completă a structurii noastre Listă cu generice, apoi vom face un pas înapoi, vom discuta despre ce sunt acestea de fapt și vom determina sintaxa lor mai formal. L-am numit TypedList
pentru a se diferenția de AnyList
anterioară:
class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T): void { this.values.push(value); } public where(predicate: (value: T) => boolean): TypedList<T> { return TypedList.from<T>(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U): TypedList<U> { return TypedList.from<U>(this.values.map(selector)); } public toArray(): T[] { return this.values; } public static from<U>(values: U[]): TypedList<U> { // Perhaps we perform some logic here. // ... return new TypedList<U>(values); } public static create<U>(values?: U[]): TypedList<U> { return new TypedList<U>(values ?? []); } // Other collection functions. // .. }
Să încercăm să facem din nou aceleași greșeli ca mai devreme:
// Here's the magic. `TypedList` will operate on objects // of type `User` due to the `<User>` syntax. const userList = TypedList.create<User>([new User('Jamie')]); // The compiler expects this. userList.add(new User('Tom')); userList.add(new User('Alice')); // Argument of type '0' is not assignable to parameter // of type 'User'. ts(2345) userList.add(0); // Property 'getNames' does not exist on type 'User'. // Did you mean 'getName'? ts(2551) // Note: TypeScript infers the type of `users` to be // `TypedList<User>` const users = userList.where(user => user.getNames() === 'Jamie'); // Property 'ssn' does not exist on type 'User'. ts(2339) users.toArray()[0].ssn = '000000000'; // TypeScript infers `usersWithId` to be of type // `TypedList<`{ id: string, name: string }> const usersWithId = userList.select(user => ({ id: newUuid(), name: user.getName() })); // Property 'ID' does not exist on type '{ id: string; name: string; }'. // Did you mean 'id'? ts(2551) console.log(usersWithId.toArray()[0].ID)
După cum puteți vedea, compilatorul TypeScript ne ajută în mod activ cu siguranța tipului. Toate aceste comentarii sunt erori pe care le primesc de la compilator când încerc să compilez acest cod. Genericurile ne-au permis să specificăm un tip pe care dorim să permitem listei noastre să opereze și de aici, TypeScript poate spune tipurile de orice, până la proprietățile obiectelor individuale din matrice.
Tipurile pe care le oferim pot fi atât de simple sau complexe pe cât ne dorim să fie. Aici, puteți vedea că putem trece atât interfețe primitive, cât și complexe. De asemenea, am putea trece și alte matrice, sau clase sau orice altceva:
const numberList = TypedList.create<number>(); numberList.add(4); const stringList = TypedList.create<string>(); stringList.add('Hello, World'); // Example of a complex type interface IAircraft { apuStatus: ApuStatus; inboardOneRPM: number; altimeter: number; tcasAlert: boolean; pushBackAndStart(): Promise<void>; ilsCaptureGlidescope(): boolean; getFuelStats(): IFuelStats; getTCASHistory(): ITCASHistory; } const aircraftList = TypedList.create<IAircraft>(); aircraftList.add(/* ... */); // Aggregate and generate report: const stats = aircraftList.select(a => ({ ...a.getFuelStats(), ...a.getTCASHistory() }));
Utilizările specifice ale T
și U
și <T>
și <U>
în implementarea TypedList<T>
sunt exemple de generice în acțiune. După ce ne-am îndeplinit directiva de a construi o structură de colecție sigură, vom lăsa acest exemplu în urmă pentru moment și vom reveni la el odată ce înțelegem ce sunt de fapt genericii, cum funcționează și sintaxa lor. Când învăț un concept nou, îmi place întotdeauna să încep prin a vedea un exemplu complex al conceptului în uz, astfel încât atunci când încep să învăț elementele de bază, să pot face conexiuni între subiectele de bază și exemplul existent pe care îl am în mine. cap.
Ce sunt genericele?
O modalitate simplă de înțelegere a genericelor este să le considerați ca fiind relativ analoge cu substituenții sau variabilele, dar pentru tipuri. Asta nu înseamnă că puteți efectua aceleași operațiuni pe un substituent de tip generic ca și o variabilă, dar o variabilă de tip generic ar putea fi considerată ca un substituent care reprezintă un tip concret care va fi folosit în viitor. Adică, utilizarea Generics este o metodă de scriere a programelor în termeni de tipuri care urmează să fie specificate la un moment ulterior în timp. Motivul pentru care acest lucru este util este că ne permite să construim structuri de date care sunt reutilizabile în diferitele tipuri pe care operează (sau agnostice de tip).
Aceasta nu este cea mai bună explicație, așa că, pentru a le spune în termeni mai simpli, așa cum am văzut, este obișnuit în programare că ar putea fi nevoie să construim o structură de funcție/clasă/date care va funcționa pe un anumit tip, dar este la fel de comun ca o astfel de structură de date trebuie să funcționeze și într-o varietate de tipuri diferite. Dacă am fi blocați într-o poziție în care ar trebui să declarăm static tipul concret pe care ar funcționa o structură de date în momentul în care proiectăm structura de date (la momentul compilării), am descoperi foarte repede că trebuie să le reconstruim. structuri în aproape exact în aceeași manieră pentru fiecare tip pe care dorim să-l susținem, așa cum am văzut în exemplele de mai sus.
Genericurile ne ajută să rezolvăm această problemă, permițându-ne să amânăm cerința pentru un tip concret până când acesta este de fapt cunoscut.
Generic în TypeScript
Acum avem o idee oarecum organică de ce sunt utile genericele și am văzut un exemplu ușor complicat de ele în practică. Pentru majoritatea, implementarea TypedList<T>
are probabil deja foarte mult sens, mai ales dacă provin dintr-o limbă tipizată static, dar îmi amintesc că mi-a fost greu să înțeleg conceptul când învățam prima dată, așa că vreau să construiți până la acel exemplu începând cu funcții simple. Conceptele legate de abstractizare în software pot fi notoriu de greu de interiorizat, așa că, dacă noțiunea de Generic nu a făcut încă clic, este complet în regulă și, să sperăm, până la închiderea acestui articol, ideea va fi cel puțin oarecum intuitivă.
Pentru a ajunge să înțelegem acest exemplu, haideți să pornim de la funcții simple. Vom începe cu „Funcția de identitate”, care este ceea ce doresc să folosească majoritatea articolelor, inclusiv documentația TypeScript în sine.
O „Funcție de identitate”, în matematică, este o funcție care își mapează intrarea direct la ieșirea, cum ar fi f(x) = x
. Ceea ce pui înăuntru este ceea ce scoți. Putem reprezenta asta, în JavaScript, ca:
function identity(input) { return input; }
Sau, mai concis:
const identity = input => input;
Încercarea de a porta acest lucru în TypeScript aduce înapoi aceleași probleme de sistem de tip pe care le-am văzut înainte. Soluțiile sunt tastarea cu any
, ceea ce știm că este rareori o idee bună, duplicarea/supraîncărcarea funcției pentru fiecare tip (rupere DRY) sau utilizarea generice.
Cu această din urmă opțiune, putem reprezenta funcția după cum urmează:
// ES5 Function function identity<T>(input: T): T { return input; } // Arrow Function const identity = <T>(input: T): T => input; console.log(identity<number>(5)); // 5 console.log(identity<string>('hello')); // hello
Sintaxa <T>
aici declară această funcție ca Generic. La fel cum o funcție ne permite să trecem un parametru de intrare arbitrar în lista de argumente, cu o funcție Generic, putem trece și un parametru de tip arbitrar.
Partea <T>
a semnăturii de identity<T>(input: T): T
și <T>(input: T): T
declară în ambele cazuri că funcția în cauză va accepta un parametru de tip generic numit T
. Așa cum variabilele pot avea orice nume, la fel și substituenții noștri generici, dar este o convenție să folosiți litera majusculă „T” (“T” pentru „Tip”) și să mutați în jos alfabetul după cum este necesar. Amintiți-vă, T
este un tip, așa că afirmăm, de asemenea, că vom accepta un argument de funcție de input
de nume cu un tip de T
și că funcția noastră va returna un tip de T
. Asta e tot ce spune semnătura. Încercați să lăsați T = string
în cap - înlocuiți toate T
-urile cu string
din acele semnături. Vezi cum nu se întâmplă nimic atât de magic? Vedeți cât de asemănător este cu modul non-generic în care utilizați funcțiile în fiecare zi?
Țineți minte ceea ce știți deja despre TypeScript și semnăturile funcțiilor. Tot ceea ce spunem este că T
este un tip arbitrar pe care utilizatorul îl va furniza atunci când apelează funcția, la fel cum input
este o valoare arbitrară pe care utilizatorul o va furniza atunci când apelează funcția. În acest caz, input
trebuie să fie oricare ar fi acel tip T
atunci când funcția este apelată în viitor .
Apoi, în „viitor”, în cele două instrucțiuni de log, „trecem” tipul concret pe care dorim să-l folosim, la fel cum facem o variabilă. Observați schimbarea verbului aici - în forma inițială a <T> signature
, atunci când declarăm funcția noastră, este generică - adică funcționează pe tipuri generice sau tipuri care urmează să fie specificate mai târziu. Asta pentru că nu știm ce tip va dori să folosească apelantul atunci când scriem funcția. Dar, atunci când apelantul apelează funcția, el/ea știe exact cu ce tip(e) vor să lucreze, care sunt string
și number
în acest caz.
Vă puteți imagina ideea de a avea o funcție de jurnal declarată astfel într-o bibliotecă terță parte - autorul bibliotecii nu are idee ce tipuri vor dori să folosească dezvoltatorii care folosesc lib, așa că fac funcția generică, amânând în esență nevoia pentru tipurile de beton până când acestea sunt efectiv cunoscute.
Vreau să subliniez că ar trebui să vă gândiți la acest proces într-un mod similar în care faceți noțiunea de a trece o variabilă la o funcție în scopul de a obține o înțelegere mai intuitivă. Tot ce facem acum este să trecem și un tip.
În punctul în care am numit funcția cu parametrul number
, semnătura originală, pentru toate scopurile, ar putea fi gândită ca identity(input: number): number
. Și, în punctul în care am apelat funcția cu parametrul string
, din nou, semnătura originală ar fi putut la fel de bine să fi fost identity(input: string): string
. Vă puteți imagina că, atunci când efectuați apelul, fiecare T
generic este înlocuit cu tipul concret pe care îl furnizați în acel moment.
Explorarea sintaxei generice
Există diferite sintaxe și semantici pentru specificarea genericelor în contextul Funcțiilor ES5, Funcțiilor săgeată, Aliasurilor de tip, Interfețelor și Claselor. Vom explora aceste diferențe în această secțiune.
Explorarea sintaxei generice — Funcții
Ați văzut până acum câteva exemple de funcții generice, dar este important să rețineți că o funcție generică poate accepta mai mult de un parametru de tip generic, la fel ca și variabile. Ai putea alege să ceri unul, sau două, sau trei, sau oricât de multe tipuri doriți, toate separate prin virgule (din nou, la fel ca argumentele de intrare).
Această funcție acceptă trei tipuri de intrare și returnează aleatoriu unul dintre ele:
function randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V { // This is a tuple if you're not familiar. const options: [T, U, V] = [ one, two, three ]; const rndNum = getRndNumInInclusiveRange(0, 2); return options[rndNum]; } // Calling the function. // `value` has type `string | number | IAircraft` const value = randomValue< string, number, IAircraft >( myString, myNumber, myAircraft );
De asemenea, puteți vedea că sintaxa este ușor diferită, în funcție de dacă folosim o funcție ES5 sau o funcție săgeată, dar ambele declară parametrii de tip în semnătură:
const randomValue = <T, U, V>( one: T, two: U, three: V ): T | U | V => { // This is a tuple if you're not familiar. const options: [T, U, V] = [ one, two, three ]; const rndNum = getRndNumInInclusiveRange(0, 2); return options[rndNum]; }
Rețineți că nu există nicio „constrângere de unicitate” impusă tipurilor - puteți trece în orice combinație doriți, cum ar fi două string
de caractere și un number
, de exemplu. În plus, la fel cum argumentele de intrare sunt „în domeniul de aplicare” pentru corpul funcției, la fel sunt și parametrii de tip generic. Primul exemplu demonstrează că avem acces deplin la T
, U
și V
din corpul funcției și le-am folosit pentru a declara un 3-tuplu local.
Vă puteți imagina că aceste generice operează într-un anumit „context” sau într-o anumită „durată de viață”, iar asta depinde de locul în care sunt declarate. Genericele pe funcții sunt în domeniul de aplicare în semnătura și corpul funcției (și închiderile create de funcții imbricate), în timp ce genericele declarate pe o clasă sau interfață sau tip alias sunt în domeniul de aplicare pentru toți membrii clasei sau interfeței sau tip alias.
Noțiunea de generice pe funcții nu se limitează la „funcții libere” sau „funcții flotante” (funcții care nu sunt atașate unui obiect sau clasă, un termen C++), dar pot fi folosite și pe funcții atașate altor structuri.
Putem plasa acel randomValue
într-o clasă și o putem numi la fel:
class Utils { public randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V { // ... } // Or, as an arrow function: public randomValue = <T, U, V>( one: T, two: U, three: V ): T | U | V => { // ... } }
De asemenea, am putea plasa o definiție într-o interfață:
interface IUtils { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }
Sau într-un tip alias:
type Utils = { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }
La fel ca și înainte, acești parametri de tip generic sunt „în domeniul de aplicare” pentru acea funcție anume - nu sunt la nivel de clasă, sau interfață sau tip alias. Ei trăiesc numai în funcția specială pentru care sunt specificate. Pentru a partaja un tip generic între toți membrii unei structuri, trebuie să adnotați numele structurii în sine, așa cum vom vedea mai jos.
Explorarea sintaxei generice — Aliasuri de tip
Cu Aliasuri de tip, sintaxa generică este utilizată pe numele aliasului.
De exemplu, o funcție „acțiune” care acceptă o valoare, eventual modifică această valoare, dar returnează void ar putea fi scrisă ca:
type Action<T> = (val: T) => void;
Notă : Acest lucru ar trebui să fie familiar dezvoltatorilor C# care înțeleg delegatul Action<T>.
Sau, o funcție de apel invers care acceptă atât o eroare, cât și o valoare ar putea fi declarată ca atare:
type CallbackFunction<T> = (err: Error, data: T) => void; const usersApi = { get(uri: string, cb: CallbackFunction<User>) { /// ... } }
Cu cunoștințele noastre despre genericii de funcții, am putea merge mai departe și am putea face și funcția de pe obiectul API generică:
type CallbackFunction<T> = (err: Error, data: T) => void; const api = { // `T` is available for use within this function. get<T>(uri: string, cb: CallbackFunction<T>) { /// ... } }
Acum, spunem că funcția get
acceptă un parametru de tip generic și orice ar fi acesta, CallbackFunction
îl primește. În esență, am „depășit” T
-ul care intră în get
ca T
pentru funcția CallbackFunction
. Poate că acest lucru ar avea mai mult sens dacă schimbăm numele:
type CallbackFunction<TData> = (err: Error, data: TData) => void; const api = { get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) { // ... } }
Prefixarea parametrilor de tip cu T
este doar o convenție, la fel ca prefixarea interfețelor cu I
sau variabilelor membre cu _
. Ceea ce puteți vedea aici este că CallbackFunction
acceptă un anumit tip ( TData
) care reprezintă încărcarea de date disponibilă pentru funcție, în timp ce get
acceptă un parametru de tip care reprezintă tipul/forma de date HTTP Response ( TResponse
). Clientul HTTP ( api
), similar cu Axios, folosește orice este TResponse
ca TData
pentru CallbackFunction
. Acest lucru permite apelantului API să selecteze tipul de date pe care îl vor primi înapoi de la API (să presupunem că în altă parte în conductă avem middleware care parsează JSON într-un DTO).
Dacă dorim să ducem acest lucru puțin mai departe, am putea modifica parametrii de tip generic pe CallbackFunction
pentru a accepta și un tip de eroare personalizat:
type CallbackFunction<TData, TError> = (err: TError, data: TData) => void;
Și, la fel cum puteți face argumentele funcției opționale, puteți și cu parametrii de tip. În cazul în care utilizatorul nu furnizează un tip de eroare, îl vom seta implicit la constructorul de erori:
type CallbackFunction<TData, TError = Error> = (err: TError, data: TData) => void;
Cu aceasta, acum putem specifica un tip de funcție de apel invers în mai multe moduri:
const apiOne = { // `Error` is used by default for `CallbackFunction`. get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) { // ... } }; apiOne.get<string>('uri', (err: Error, data: string) => { // ... }); const apiTwo = { // Override the default and use `HttpError` instead. get<TResponse>(uri: string, cb: CallbackFunction<TResponse, HttpError>) { // ... } }; apiTwo.get<string>('uri', (err: HttpError, data: string) => { // ... });
Această idee a parametrilor impliciti este acceptabilă pentru funcții, clase, interfețe și așa mai departe - nu se limitează doar la aliasuri de tip. În toate exemplele pe care le-am văzut până acum, am fi putut atribui orice parametru de tip pe care l-am dorit unei valori implicite. Aliasurile de tip, la fel ca și funcțiile, pot lua oricât de mulți parametri de tip generic doriți.
Explorarea sintaxei generice — Interfețe
După cum ați văzut, un parametru de tip generic poate fi furnizat unei funcții pe o interfață:
interface IUselessFunctions { // Not generic printHelloWorld(); // Generic identity<T>(t: T): T; }
În acest caz, T
trăiește numai pentru funcția de identity
ca tip de intrare și returnare.
De asemenea, putem face un parametru de tip disponibil pentru toți membrii unei interfețe, la fel ca în cazul claselor și al aliasurilor de tip, specificând că interfața în sine acceptă un generic. Vom vorbi despre modelul de depozit puțin mai târziu când vom discuta cazuri de utilizare mai complexe pentru generice, așa că este în regulă dacă nu ați auzit niciodată de el. Modelul de depozit ne permite să ne abstragem stocarea de date pentru a face logica de afaceri agnostică a persistenței. Dacă doriți să creați o interfață de depozit generică care să funcționeze pe tipuri de entități necunoscute, am putea să o introducem după cum urmează:
interface IRepository<T> { add(entity: T): Promise<void>; findById(id: string): Promise<T>; updateById(id: string, updated: T): Promise<void>; removeById(id: string): Promise<void>; }
Notă : Există multe gânduri diferite în jurul depozitelor, de la definiția lui Martin Fowler la definiția agregată DDD. Încerc doar să arăt un caz de utilizare pentru generice, așa că nu sunt prea preocupat să fiu complet corect din punct de vedere al implementării. Cu siguranță este ceva de spus pentru a nu folosi depozitele generice, dar despre asta vom vorbi mai târziu.
După cum puteți vedea aici, IRepository
este o interfață care conține metode pentru stocarea și preluarea datelor. Funcționează pe un parametru de tip generic numit T
și T
este folosit ca intrare pentru a add
și updateById
, precum și rezultatul rezoluției promisiunii al findById
.
Rețineți că există o diferență foarte mare între acceptarea unui parametru de tip generic pe numele interfeței, spre deosebire de a permite fiecărei funcție în sine să accepte un parametru de tip generic. Primul, așa cum am făcut aici, asigură că fiecare funcție din interfață funcționează pe același tip T
. Adică, pentru un IRepository<User>
, fiecare metodă care folosește T
în interfață funcționează acum pe obiecte User
. Cu această ultimă metodă, fiecărei funcții i se va permite să funcționeze cu orice tip dorește. Ar fi foarte ciudat să putem adăuga User
numai în Arhivă, dar să putem primi Policies
sau Orders
înapoi, de exemplu, care este situația potențială în care ne-am afla dacă nu am putea impune că tipul este uniform în toate metodele.
O interfață dată poate conține nu numai un tip partajat, ci și tipuri unice pentru membrii săi. De exemplu, dacă dorim să imităm o matrice, am putea introduce o interfață ca aceasta:
interface IArray<T> { forEach(func: (elem: T, index: number) => void): this; map<U>(func: (elem: T, index: number) => U): IArray<U>; }
În acest caz, atât forEach
, cât și map
au acces la T
din numele interfeței. După cum sa spus, vă puteți imagina că T
este în domeniul de aplicare pentru toți membrii interfeței. În ciuda acestui fapt, nimic nu oprește funcțiile individuale din interior să accepte și propriile parametri de tip. Funcția de map
face, cu U
. Acum, map
are acces atât la T
, cât și la U
A trebuit să denumim parametrul cu o altă literă, cum ar fi U
, deoarece T
este deja luat și nu vrem o coliziune de denumire. La fel ca și numele său, map
va „mapa” elemente de tip T
din matrice la noi elemente de tip U
Mapează T
s la U
s. Valoarea de returnare a acestei funcții este interfața însăși, care operează acum pe noul tip U
, astfel încât să putem imita într-o oarecare măsură sintaxa înlănțuitoare a JavaScript-ului pentru matrice.
Vom vedea un exemplu de putere a genericelor și a interfețelor în scurt timp când implementăm modelul de depozit și vom discuta despre injectarea dependenței. Încă o dată, putem accepta cât mai mulți parametri generici precum și selectați unul sau mai mulți parametri impliciti stivuiți la sfârșitul unei interfețe.
Explorarea sintaxei generice — Clase
La fel cum putem transmite un parametru de tip generic unui alias de tip, funcție sau interfață, putem transmite unul sau mai multe unei clase. După ce faceți acest lucru, acel parametru de tip va fi accesibil tuturor membrilor acelei clase, precum și claselor de bază extinse sau interfețelor implementate.
Să construim o altă clasă de colecție, dar puțin mai simplă decât TypedList
de mai sus, astfel încât să putem vedea interoperabilitatea dintre tipurile generice, interfețele și membrii. Vom vedea un exemplu de transmitere a unui tip la o clasă de bază și moștenirea interfeței puțin mai târziu.
Colecția noastră va suporta doar funcții CRUD de bază, pe lângă o map
și forEach
pentru fiecare.
class Collection<T> { private elements: T[] = []; constructor (elements: T[] = []) { this.elements = elements; } add(elem: T): void { this.elements.push(elem); } contains(elem: T): boolean { return this.elements.includes(elem); } remove(elem: T): void { this.elements = this.elements.filter(existing => existing !== elem); } forEach(func: (elem: T, index: number) => void): void { return this.elements.forEach(func); } map<U>(func: (elem: T, index: number) => U): Collection<U> { return new Collection<U>(this.elements.map(func)); } } const stringCollection = new Collection<string>(); stringCollection.add('Hello, World!'); const numberCollection = new Collection<number>(); numberCollection.add(3.14159); const aircraftCollection = new Collection<IAircraft>(); aircraftCollection.add(myAircraft);
Să discutăm despre ce se întâmplă aici. Clasa Collection
acceptă un parametru de tip generic numit T
. Acest tip devine accesibil tuturor membrilor clasei. Îl folosim pentru a defini un tablou privat de tip T[]
, pe care l-am fi putut notat și sub forma Array<T>
(Vezi? Generics din nou pentru tastarea normală a matricei TS). În plus, majoritatea funcțiilor membre utilizează acel T
într-un fel, cum ar fi controlând tipurile care sunt adăugate și eliminate sau verificând dacă colecția conține un element.
În cele din urmă, așa cum am văzut anterior, metoda map
necesită propriul parametru de tip generic. We need to define in the signature of map
that some type T
is mapped to some type U
through a callback function, thus we need a U
. That U
is unique to that function in particular, which means we could have another function in that class that also accepts some type named U
, and that'd be fine, because those types are only “in scope” for their functions and not shared across them, thus there are no naming collisions. What we can't do is have another function that accepts a generic parameter named T
, for that'd conflict with the T
from the class signature.
You can see that when we call the constructor, we pass in the type we want to work with (that is, what type each element of the internal array will be). In the calling code at the bottom of the example, we work with string
s, number
s, and IAircraft
s.
How could we make this work with an interface? What if we have different collection interfaces that we might want to swap out or inject into calling code? To get that level of reduced coupling (low coupling and high cohesion is what we should always aim for), we'll need to depend on an abstraction. Generally, that abstraction will be an interface, but it could also be an abstract class.
Our collection interface will need to be generic, so let's define it:
interface ICollection<T> { add(t: T): void; contains(t: T): boolean; remove(t: T): void; forEach(func: (elem: T, index: number) => void): void; map<U>(func: (elem: T, index: number) => U): ICollection<U>; }
Now, let's suppose we have different kinds of collections. We could have an in-memory collection, one that stores data on disk, one that uses a database, and so on. By having an interface, the dependent code can depend upon the abstraction, permitting us to swap out different implementations without affecting the existing code. Here is the in-memory collection.
class InMemoryCollection<T> implements ICollection<T> { private elements: T[] = []; constructor (elements: T[] = []) { this.elements = elements; } add(elem: T): void { this.elements.push(elem); } contains(elem: T): boolean { return this.elements.includes(elem); } remove(elem: T): void { this.elements = this.elements.filter(existing => existing !== elem); } forEach(func: (elem: T, index: number) => void): void { return this.elements.forEach(func); } map<U>(func: (elem: T, index: number) => U): ICollection<U> { return new InMemoryCollection<U>(this.elements.map(func)); } }
The interface describes the public-facing methods and properties that our class is required to implement, expecting you to pass in a concrete type that those methods will operate upon. However, at the time of defining the class, we still don't know what type the API caller will wish to use. Thus, we make the class generic too — that is, InMemoryCollection
expects to receive some generic type T
, and whatever it is, it's immediately passed to the interface, and the interface methods are implemented using that type.
Calling code can now depend on the interface:
// Using type annotation to be explicit for the purposes of the // tutorial. const userCollection: ICollection<User> = new InMemoryCollection<User>(); function manageUsers(userCollection: ICollection<User>) { userCollection.add(new User()); }
With this, any kind of collection can be passed into the manageUsers
function as long as it satisfies the interface. This is useful for testing scenarios — rather than dealing with over-the-top mocking libraries, in unit and integration test scenarios, I can replace my SqlServerCollection<T>
(for example) with InMemoryCollection<T>
instead and perform state-based assertions instead of interaction-based assertions. This setup makes my tests agnostic to implementation details, which means they are, in turn, less likely to break when refactoring the SUT.
At this point, we should have worked up to the point where we can understand that first TypedList<T>
example. Here it is again:
class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T): void { this.values.push(value); } public where(predicate: (value: T) => boolean): TypedList<T> { return TypedList.from<T>(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U): TypedList<U> { return TypedList.from<U>(this.values.map(selector)); } public toArray(): T[] { return this.values; } public static from<U>(values: U[]): TypedList<U> { // Perhaps we perform some logic here. // ... return new TypedList<U>(values); } public static create<U>(values?: U[]): TypedList<U> { return new TypedList<U>(values ?? []); } // Other collection functions. // .. }
The class itself accepts a generic type parameter named T
, and all members of the class are provided access to it. The instance method select
and the two static methods from
and create
, which are factories, accept their own generic type parameter named U
.
The create
static method permits the construction of a list with optional seed data. It accepts some type named U
to be the type of every element in the list as well as an optional array of U
elements, typed as U[]
. When it calls the list's constructor with new
, it passes that type U
as the generic parameter to TypedList
. This creates a new list where the type of every element is U
. It is exactly the same as how we could call the constructor of our collection class earlier with new Collection<SomeType>()
. The only difference is that the generic type is now passing through the create
method rather than being provided and used at the top-level.
I want to make sure this is really, really clear. I've stated a few times that we can think about passing around types in a similar way that we do variables. It should already be quite intuitive that we can pass a variable through as many layers of indirection as we please. Forget generics and types for a moment and think about an example of the form:
class MyClass { private constructor (t: number) {} public static create(u: number) { return new MyClass(u); } } const myClass = MyClass.create(2.17);
This is very similar to what is happening with the more-involved example, the difference being that we're working on generic type parameters, not variables. Here, 2.17
becomes the u
in create
, which ultimately becomes the t
in the private constructor.
In the case of generics:
class MyClass<T> { private constructor () {} public static create<U>() { return new MyClass<U>(); } } const myClass = MyClass.create<number>();
The U
passed to create
is ultimately passed in as the T
for MyClass<T>
. When calling create
, we provided number
as U
, thus now U = number
. We put that U
(which, again, is just number
) into the T
for MyClass<T>
, so that MyClass<T>
effectively becomes MyClass<number>
. The benefit of generics is that we're opened up to be able to work with types in this abstract and high-level fashion, similar to how we can normal variables.
The from
method constructs a new list that operates on an array of elements of type U
. It uses that type U
, just like create
, to construct a new instance of the TypedList
class, now passing in that type U
for T
.
The where
instance method performs a filtering operation based upon a predicate function. There's no mapping happening, thus the types of all elements remain the same throughout. The filter
method available on JavaScript's array returns a new array of values, which we pass into the from
method. So, to be clear, after we filter out the values that don't satisfy the predicate function, we get an array back containing the elements that do. All those elements are still of type T
, which is the original type that the caller passed to create
when the list was first created. Those filtered elements get given to the from
method, which in turn creates a new list containing all those values, still using that original type T
. The reason why we return a new instance of the TypedList
class is to be able to chain new method calls onto the return result. This adds an element of “immutability” to our list.
Hopefully, this all provides you with a more intuitive example of generics in practice, and their reason for existence. Next, we'll look at a few of the more advanced topics.
Generic Type Inference
Throughout this article, in all cases where we've used generics, we've explicitly defined the type we're operating on. It's important to note that in most cases, we do not have to explicitly define the type parameter we pass in, for TypeScript can infer the type based on usage.
If I have some function that returns a random number, and I pass the return result of that function to identity
from earlier without specifying the type parameter, it will be inferred automatically as number
:
// `value` is inferred as type `number`. const value = identity(getRandomNumber());
Pentru a demonstra inferența tipului, am eliminat mai devreme toate adnotările de tip străine din punct de vedere tehnic din structura noastră TypedList
și puteți vedea, din imaginile de mai jos, că TSC încă deduce corect toate tipurile:
TypedList
fără declarații de tip străin:
class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T) { this.values.push(value); } public where(predicate: (value: T) => boolean) { return TypedList.from(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U) { return TypedList.from(this.values.map(selector)); } public toArray() { return this.values; } public static from<U>(values: U[]) { // Perhaps we perform some logic here. // ... return new TypedList(values); } public static create<U>(values?: U[]) { return new TypedList(values ?? []); } // Other collection functions. // .. }
Pe baza valorilor returnate ale funcției și pe baza tipurilor de intrare transmise from
și constructorului, TSC înțelege toate informațiile de tip. În imaginea de mai jos, am îmbinat mai multe imagini care arată Extensia de limbaj a Code TypeScript a Visual Studio (și, prin urmare, compilatorul) deducând toate tipurile:
Constrângeri generice
Uneori, vrem să punem o constrângere în jurul unui tip generic. Poate că nu putem susține toate tipurile existente, dar putem susține un subset al acestora. Să presupunem că vrem să construim o funcție care returnează lungimea unei colecții. După cum am văzut mai sus, am putea avea multe tipuri diferite de matrice/colecții, de la Array
JavaScript implicită la cele personalizate. Cum anunțăm funcția noastră că un tip generic are o proprietate de length
atașată? În mod similar, cum limităm tipurile concrete pe care le trecem în funcție la cele care conțin datele de care avem nevoie? Un exemplu ca acesta, de exemplu, nu ar funcționa:
function getLength<T>(collection: T): number { // Error. TS does not know that a type T contains a `length` property. return collection.length; }
Răspunsul este să utilizați constrângeri generice. Putem defini o interfață care descrie proprietățile de care avem nevoie:
interface IHasLength { length: number; }
Acum, când definim funcția noastră generică, putem constrânge tipul generic să fie unul care extinde acea interfață:
function getLength<T extends IHasLength>(collection: T): number { // Restricting `collection` to be a type that contains // everything within the `IHasLength` interface. return collection.length; }
Exemple din lumea reală
În următoarele două secțiuni, vom discuta câteva exemple din lumea reală de generice care creează un cod mai elegant și mai ușor de raționat. Am văzut o mulțime de exemple banale, dar vreau să discut despre câteva abordări ale gestionării erorilor, tiparelor de acces la date și starea/procesele front-end React.
Exemple din lumea reală — Abordări ale gestionării erorilor
JavaScript conține un mecanism de primă clasă pentru gestionarea erorilor, la fel ca majoritatea limbajelor de programare - try
/ catch
. În ciuda acestui fapt, nu sunt un mare fan al modului în care arată când este folosit. Asta nu înseamnă că nu folosesc mecanismul, da, dar tind să încerc să-l ascund cât de mult pot. Prin abstracția try
/ catch
away, pot reutiliza, de asemenea, logica de gestionare a erorilor în operațiunile cu probabilitate de eșec.
Să presupunem că construim un strat de acces la date. Acesta este un strat al aplicației care înglobează logica de persistență pentru a trata metoda de stocare a datelor. Dacă efectuăm operațiuni de bază de date și dacă acea bază de date este utilizată într-o rețea, este probabil să apară anumite erori specifice DB și excepții tranzitorii. O parte din motivul pentru care aveți un Strat de acces la date dedicat este abstracția bazei de date din logica de afaceri. Din această cauză, nu putem avea astfel de erori specifice DB să fie aruncate în stivă și din acest strat. Trebuie să le înfășurăm mai întâi.
Să ne uităm la o implementare tipică care ar folosi try
/ catch
:
async function queryUser(userID: string): Promise<User> { try { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(dbUser); } catch (e) { switch (true) { case e instanceof DbErrorOne: return Promise.reject(new WrapperErrorOne()); case e instanceof DbErrorTwo: return Promise.reject(new WrapperErrorTwo()); case e instanceof NetworkError: return Promise.reject(new TransientException()); default: return Promise.reject(new UnknownError()); } } }
Comutarea la true
este doar o metodă pentru a putea folosi instrucțiunile switch case
pentru logica mea de verificare a erorilor, spre deosebire de a trebui să declar un lanț de if/else if - un truc pe care l-am auzit prima dată de la @Jeffijoe.
Dacă avem mai multe astfel de funcții, trebuie să replicăm această logică de împachetare a erorilor, care este o practică foarte proastă. Arată destul de bine pentru o singură funcție, dar va fi un coșmar pentru multe. Pentru a abstrage această logică, o putem îngloba într-o funcție personalizată de gestionare a erorilor care va trece prin rezultat, dar captează și împachetează orice erori în cazul în care acestea sunt aruncate:
async function withErrorHandling<T>( dalOperation: () => Promise<T> ): Promise<T> { try { // This unwraps the promise and returns the type `T`. return await dalOperation(); } catch (e) { switch (true) { case e instanceof DbErrorOne: return Promise.reject(new WrapperErrorOne()); case e instanceof DbErrorTwo: return Promise.reject(new WrapperErrorTwo()); case e instanceof NetworkError: return Promise.reject(new TransientException()); default: return Promise.reject(new UnknownError()); } } }
Pentru a ne asigura că acest lucru are sens, avem o funcție intitulată withErrorHandling
care acceptă un parametru de tip generic T
. Acest T
reprezintă tipul valorii de rezoluție de succes a promisiunii pe care o așteptăm returnată de la funcția de apel invers dalOperation
. De obicei, din moment ce doar returnăm rezultatul returnat al funcției asincrone dalOperation
, nu ar fi nevoie să-l await
pentru că aceasta ar încheia funcția într-o a doua promisiune străină și am putea lăsa await
codului apelant. În acest caz, trebuie să detectăm orice erori, deci este necesară await
.
Acum putem folosi această funcție pentru a încheia operațiunile noastre DAL de mai devreme:
async function queryUser(userID: string) { return withErrorHandling<User>(async () => { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(dbUser); }); }
Și iată-ne. Avem o funcție de interogare a utilizatorului cu funcție de tip sigur și de eroare.
În plus, după cum ați văzut mai devreme, dacă compilatorul TypeScript are suficiente informații pentru a deduce implicit tipurile, nu trebuie să le transmiteți în mod explicit. În acest caz, TSC știe că rezultatul returnat al funcției este tipul generic. Astfel, dacă mapper.toDomain(user)
a returnat un tip de User
, nu ar trebui să treceți deloc tipul:
async function queryUser(userID: string) { return withErrorHandling(async () => { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(user); }); }
O altă abordare a gestionării erorilor care tind să-mi placă este cea a tipurilor monadice. Monada Fie este un tip de date algebrice de forma Either<T, U>
, unde T
poate reprezenta un tip de eroare, iar U
poate reprezenta un tip de eroare. Utilizarea tipurilor Monadic ascultă programarea funcțională, iar un avantaj major este că erorile devin sigure pentru tipare - o semnătură obișnuită a funcției nu îi spune apelantului API nimic despre erorile pe care le-ar putea produce acea funcție. Să presupunem că aruncăm o eroare NotFound
din interiorul queryUser
. O semnătură a lui queryUser(userID: string): Promise<User>
nu ne spune nimic despre asta. Dar, o semnătură precum queryUser(userID: string): Promise<Either<NotFound, User>>
face absolut. Nu voi explica cum funcționează monade precum Either Monad în acest articol, deoarece pot fi destul de complexe și există o varietate de metode pe care trebuie să le aibă pentru a fi considerate monadice, cum ar fi maparea/legarea. Dacă doriți să aflați mai multe despre ele, v-aș recomanda două dintre discuțiile NDC ale lui Scott Wlaschin, aici și aici, precum și discursul lui Daniel Chamber aici. Acest site, precum și aceste postări de blog pot fi și ele utile.
Exemple din lumea reală — Model de depozit
Să aruncăm o privire la un alt caz de utilizare în care genericele ar putea fi de ajutor. Majoritatea sistemelor back-end trebuie să interfațeze cu o bază de date într-un fel - aceasta ar putea fi o bază de date relațională precum PostgreSQL, o bază de date de documente precum MongoDB sau poate chiar o bază de date grafică, cum ar fi Neo4j.
Deoarece, în calitate de dezvoltatori, ar trebui să urmărim proiecte cuplate slab și foarte coezive, ar fi un argument corect să luăm în considerare care ar putea fi ramificațiile sistemelor de baze de date migratoare. De asemenea, ar fi corect să luăm în considerare că diferitele nevoi de acces la date ar putea prefera abordări diferite de acces la date (acest lucru începe să intre puțin în CQRS, care este un model pentru separarea citirilor și scrierilor. Consultați postarea lui Martin Fowler și lista MSDN dacă doriți pentru a afla mai multe. Cărțile „Implementing Domain Driven Design” de Vaughn Vernon și „Patterns, Principles, and Practices of Domain-Driven Design” de Scott Millet sunt de asemenea bune citiri). Ar trebui să luăm în considerare și testarea automată. Majoritatea tutorialelor care explică construirea sistemelor back-end cu Node.js amestecă codul de acces la date cu logica de afaceri cu rutarea. Adică, au tendința de a utiliza MongoDB cu Mongoose ODM, adoptând o abordare Active Record și neavând o separare clară a preocupărilor. Astfel de tehnici sunt descurajate în aplicații mari; în momentul în care decideți că doriți să migrați un sistem de baze de date pentru altul sau în momentul în care vă dați seama că ați prefera o abordare diferită a accesului la date, trebuie să scoateți acel vechi cod de acces la date, să îl înlocuiți cu un cod nou, și sper că nu ați introdus erori în rutare și logica de afaceri pe parcurs.
Sigur, ați putea argumenta că testele unitare și de integrare vor preveni regresiile, dar dacă acele teste se găsesc cuplate și dependente de detaliile de implementare la care ar trebui să fie agnostice, probabil că și ele se vor rupe în proces.
O abordare comună pentru a rezolva această problemă este Repository Pattern. Se spune că, la codul de apel, ar trebui să permitem stratului nostru de acces la date să imite o simplă colecție în memorie de obiecte sau entități de domeniu. În acest fel, putem lăsa afacerea să conducă designul mai degrabă decât baza de date (model de date). Pentru aplicații mari, un model arhitectural numit Domain-Driven Design devine util. Arhivele, în Repository Pattern, sunt componente, cel mai frecvent clase, care încapsulează și dețin în interior toată logica pentru a accesa sursele de date. Cu aceasta, putem centraliza codul de acces la date la un singur strat, făcându-l ușor de testat și ușor de reutilizat. În plus, putem plasa un strat de mapare între ele, permițându-ne să mapam modele de domenii independente de baze de date la o serie de mapări de tabel unu-la-unu. Fiecare funcție disponibilă în Repository poate folosi opțional o metodă diferită de acces la date dacă alegeți acest lucru.
Există multe abordări și semantică diferite pentru depozite, unități de lucru, tranzacții de baze de date între tabele și așa mai departe. Deoarece acesta este un articol despre Generic, nu vreau să intru prea mult în buruieni, așa că voi ilustra aici un exemplu simplu, dar este important să rețineți că diferite aplicații au nevoi diferite. Un depozit pentru agregate DDD ar fi destul de diferit de ceea ce facem noi aici, de exemplu. Modul în care descriu implementările Repository aici nu este modul în care le implementez în proiecte reale, deoarece lipsesc o mulțime de funcționalități și practici arhitecturale mai puțin decât cele dorite în uz.
Să presupunem că avem Users
și Tasks
ca modele de domeniu. Acestea ar putea fi doar POTO-uri — obiecte TypeScript simple și vechi. Nu există nicio noțiune de bază de date încorporată în ele, prin urmare, nu ați apela User.save()
, de exemplu, așa cum ați face cu Mongoose. Folosind modelul de depozit, este posibil să persistăm un utilizator sau să ștergem o sarcină din logica noastră de afaceri, după cum urmează:
// Querying the DB for a User by their ID. const user: User = await userRepository.findById(userID); // Deleting a Task by its ID. await taskRepository.deleteById(taskID); // Deleting a Task by its owner's ID. await taskRepository.deleteByUserId(userID);
În mod clar, puteți vedea cum toată logica dezordonată și tranzitorie de acces la date este ascunsă în spatele acestei fațade/abstracție a depozitului, făcând logica de afaceri agnostică față de preocupările de persistență.
Să începem prin a construi câteva modele simple de domenii. Acestea sunt modelele cu care va interacționa codul aplicației. Sunt anemici aici, dar și-ar păstra propria logică pentru a satisface invarianții de afaceri din lumea reală, adică nu ar fi simple pungi de date.
interface IHasIdentity { id: string; } class User implements IHasIdentity { public constructor ( private readonly _id: string, private readonly _username: string ) {} public get id() { return this._id; } public get username() { return this._username; } } class Task implements IHasIdentity { public constructor ( private readonly _id: string, private readonly _title: string ) {} public get id() { return this._id; } public get title() { return this._title; } }
Veți vedea într-un moment de ce extragem informațiile de tastare a identității într-o interfață. Această metodă de a defini modelele de domenii și de a trece totul prin constructor nu este așa cum aș face-o în lumea reală. În plus, bazarea pe o clasă de model de domeniu abstract ar fi fost mai de preferat decât interfața pentru a obține implementarea id
-ului gratuit.
Pentru depozit, deoarece, în acest caz, ne așteptăm ca multe dintre aceleași mecanisme de persistență să fie partajate între diferite modele de domenii, putem abstractiza metodele noastre de depozit la o interfață generică:
interface IRepository<T> { add(entity: T): Promise<void>; findById(id: string): Promise<T>; updateById(id: string, updated: T): Promise<void>; deleteById(id: string): Promise<void>; existsById(id: string): Promise<boolean>; }
Am putea merge mai departe și am crea și un depozit generic pentru a reduce duplicarea. Pentru concizie, nu voi face asta aici și ar trebui să remarc că interfețele Generic Repository, cum ar fi aceasta și Generic Repositories, în general, tind să fie descurajate, deoarece este posibil să aveți anumite entități care sunt doar pentru citire sau scrie -numai, sau care nu poate fi ștearsă, sau similar. Depinde de aplicație. De asemenea, nu avem o noțiune de „unitate de lucru” pentru a partaja o tranzacție între mese, o caracteristică pe care aș implementa-o în lumea reală, dar, din nou, din moment ce acesta este un mic demo, nu vreau să devin prea tehnic.
Să începem prin a implementa UserRepository
. Voi defini o interfață IUserRepository
care deține metode specifice utilizatorilor, permițând astfel codului de apel să depindă de acea abstracție atunci când injectăm dependența implementărilor concrete:
interface IUserRepository extends IRepository<User> { existsByUsername(username: string): Promise<boolean>; } class UserRepository implements IUserRepository { // There are 6 methods to implement here all using the // concrete type of `User` - Five from IRepository<User> // and the one above. }
Task Repository ar fi similar, dar ar conține metode diferite, după cum consideră de cuviință aplicația.
Aici, definim o interfață care extinde una generică, astfel încât trebuie să trecem tipul concret la care lucrăm. După cum puteți vedea din ambele interfețe, avem ideea că trimitem aceste modele de domenii POTO și le scoatem. Codul de apelare nu are idee care este mecanismul de persistență de bază și acesta este ideea.
Următoarea considerație de luat este că, în funcție de metoda de acces la date pe care o alegem, va trebui să gestionăm erorile specifice bazei de date. Am putea plasa Mongoose sau Knex Query Builder în spatele acestui Repository, de exemplu, și, în acest caz, va trebui să ne ocupăm de acele erori specifice - nu vrem ca ele să se ridice la nivelul logicii de afaceri, deoarece asta ar rupe separarea preocupărilor. si introduce un grad mai mare de cuplare.
Să definim un depozit de bază pentru metodele de acces la date pe care dorim să le folosim, care poate gestiona erorile pentru noi:
class BaseKnexRepository { // A constructor. /** * Wraps a likely to fail database operation within a function that handles errors by catching * them and wrapping them in a domain-safe error. * * @param dalOp The operation to perform upon the database. */ public async withErrorHandling<T>(dalOp: () => Promise<T>) { try { return await dalOp(); } catch (e) { // Use a proper logger: console.error(e); // Handle errors properly here. } } }
Acum, putem extinde această clasă de bază în depozit și accesăm acea metodă generică:
interface IUserRepository extends IRepository<User> { existsByUsername(username: string): Promise<boolean>; } class UserRepository extends BaseKnexRepository implements IUserRepository { private readonly dbContext: Knex | Knex.Transaction; public constructor (private knexInstance: Knex | Knex.Transaction) { super(); this.dbContext = knexInstance; } // Example `findById` implementation: public async findById(id: string): Promise<User> { return this.withErrorHandling<User>(async () => { const dbUser = await this.dbContext<DbUser>() .select() .where({ user_id: id }) .first(); // Maps type DbUser to User return mapper.toDomain(dbUser); }); } // There are 5 methods to implement here all using the // concrete type of `User`. }
Observați că funcția noastră preia un DbUser
din baza de date și îl mapează la un model de domeniu de User
înainte de a-l returna. Acesta este modelul Data Mapper și este esențial pentru menținerea separării preocupărilor. DbUser
este o mapare unu-la-unu la tabelul bazei de date - este modelul de date pe care operează Depozitul - și, prin urmare, este foarte dependent de tehnologia de stocare a datelor utilizată. Din acest motiv, utilizatorii DbUser
nu vor părăsi niciodată Depozitul și vor fi mapați la un model de domeniu de User
înainte de a fi returnați. Nu am arătat implementarea DbUser
, dar ar putea fi doar o clasă sau o interfață simplă.
Până acum, folosind Repository Pattern, alimentat de Generics, am reușit să reținem problemele legate de accesul la date în unități mici, precum și să menținem siguranța tipului și reutilizarea.
În cele din urmă, în scopul testării unității și a integrării, să presupunem că vom păstra o implementare a depozitului în memorie, astfel încât într-un mediu de testare, să putem injecta acel depozit și să realizăm afirmații bazate pe stare pe disc, mai degrabă decât să ne batem joc cu un cadru batjocoritor. Această metodă obligă totul să se bazeze pe interfețele publice, mai degrabă decât să permită cuplarea testelor cu detaliile de implementare. Deoarece singurele diferențe dintre fiecare depozit sunt metodele pe care aleg să le adauge în interfața ISomethingRepository
, putem construi un depozit generic în memorie și îl putem extinde în cadrul implementărilor specifice tipului:
class InMemoryRepository<T extends IHasIdentity> implements IRepository<T> { protected entities: T[] = []; public findById(id: string): Promise<T> { const entityOrNone = this.entities.find(entity => entity.id === id); return entityOrNone ? Promise.resolve(entityOrNone) : Promise.reject(new NotFound()); } // Implement the rest of the IRepository<T> methods here. }
Scopul acestei clase de bază este de a efectua toată logica pentru gestionarea stocării în memorie, astfel încât să nu fie nevoie să o duplicăm în depozitele de testare în memorie. Datorită metodelor precum findById
, acest depozit trebuie să înțeleagă că entitățile conțin un câmp id
, motiv pentru care este necesară constrângerea generică pe interfața IHasIdentity
. Am mai văzut această interfață – este ceea ce modelele noastre de domeniu au implementat.
Cu aceasta, atunci când vine vorba de construirea utilizatorului în memorie sau a depozitului de sarcini, putem doar să extindem această clasă și să obținem cele mai multe dintre metodele implementate automat:
class InMemoryUserRepository extends InMemoryRepository<User> { public async existsByUsername(username: string): Promise<boolean> { const userOrNone = this.entities.find(entity => entity.username === username); return Boolean(userOrNone); // or, return !!userOrNone; } // And that's it here. InMemoryRepository implements the rest. }
Aici, InMemoryRepository
trebuie să știe că entitățile au câmpuri precum id
și username
, astfel că trecem User
ca parametru generic. User
implementează deja IHasIdentity
, astfel încât constrângerea generică este satisfăcută și, de asemenea, afirmăm că avem și o proprietate de username
de utilizator.
Acum, când dorim să folosim aceste depozite din Stratul de logică de afaceri, este destul de simplu:
class UserService { public constructor ( private readonly userRepository: IUserRepository, private readonly emailService: IEmailService ) {} public async createUser(dto: ICreateUserDTO) { // Validate the DTO: // ... // Create a User Domain Model from the DTO const user = userFactory(dto); // Persist the Entity await this.userRepository.add(user); // Send a welcome email await this.emailService.sendWelcomeEmail(user); } }
(Rețineți că într-o aplicație reală, probabil că am muta apelul către emailService
într-o coadă de locuri de muncă pentru a nu adăuga latență cererii și în speranța de a putea efectua reîncercări idempotente în cazul eșecurilor (— nu că trimiterea de e-mail este deosebit de importantă). idempotent în primul rând). În plus, transmiterea întregului obiect utilizator către serviciu este de asemenea discutabilă. Cealaltă problemă de reținut este că ne-am putea găsi aici într-o poziție în care serverul se blochează după ce utilizatorul este persistent, dar înainte ca e-mailul să fie trimis. Există modele de atenuare pentru a preveni acest lucru, dar în scopuri de pragmatism, intervenția umană cu o înregistrare adecvată va funcționa probabil foarte bine).
Și iată-ne - folosind modelul de depozit cu puterea genericelor, ne-am decuplat complet DAL-ul nostru de BLL și am reușit să interacționăm cu depozitul nostru într-un mod sigur. Am dezvoltat, de asemenea, o modalitate de a construi rapid depozite în memorie la fel de sigure pentru tipuri, în scopul testării unitare și de integrare, permițând adevărate teste negre și independente de implementare. Nimic din toate acestea nu ar fi fost posibil fără tipurile generice.
Ca o declinare a răspunderii, vreau să remarc încă o dată că această implementare a depozitului lipsește mult. Am vrut să păstrez exemplul simplu, deoarece se pune accentul pe utilizarea genericelor, motiv pentru care nu m-am ocupat de duplicare și nu m-am îngrijorat de tranzacții. Implementările decente ale depozitelor ar necesita un articol de unul singur pentru a explica complet și corect, iar detaliile de implementare se schimbă în funcție de faptul că faci arhitectură N-Tier sau DDD. Aceasta înseamnă că, dacă doriți să utilizați modelul de depozit, nu ar trebui să vă uitați la implementarea mea aici ca în vreun fel o bună practică.
Exemple din lumea reală — React State & Props
Starea, ref și restul cârligelor pentru componentele funcționale React sunt și ele generice. Dacă am o interfață care conține proprietăți pentru Task
și vreau să păstrez o colecție a acestora într-o componentă React, aș putea face acest lucru după cum urmează:
import React, { useState } from 'react'; export const MyComponent: React.FC = () => { // An empty array of tasks as the initial state: const [tasks, setTasks] = useState<Task[]>([]); // A counter: // Notice, type of `number` is inferred automatically. const [counter, setCounter] = useState(0); return ( <div> <h3>Counter Value: {counter}</h3> <ul> { tasks.map(task => ( <li key={task.id}> <TaskItem {...task} /> </li> )) } </ul> </div> ); };
În plus, dacă dorim să trecem o serie de elemente de recuzită în funcția noastră, putem folosi tipul generic React.FC<T>
și obținem acces la elemente de props
:
import React from 'react'; interface IProps { id: string; title: string; description: string; } export const TaskItem: React.FC<IProps> = (props) => { return ( <div> <h3>{props.title}</h3> <p>{props.description}</p> </div> ); };
Tipul de props
este dedus automat ca fiind IProps
de către compilatorul TS.
Concluzie
În acest articol, am văzut multe exemple diferite de generice și cazurile lor de utilizare, de la colecții simple, la abordări de gestionare a erorilor, la izolarea stratului de acces la date și așa mai departe. În cei mai simpli termeni, Generics ne permit să construim structuri de date fără a fi nevoie să cunoaștem timpul concret în care acestea vor funcționa la compilare. Sperăm că acest lucru ajută la deschiderea un pic mai mult subiectul, face noțiunea de generice puțin mai intuitivă și aduce adevărata lor putere.