Memahami Generik TypeScript
Diterbitkan: 2022-03-10Pada artikel ini, kita akan mempelajari konsep Generics dalam TypeScript dan memeriksa bagaimana Generics dapat digunakan untuk menulis kode modular, decoupled, dan dapat digunakan kembali. Sepanjang jalan, kami akan membahas secara singkat bagaimana mereka cocok dengan pola pengujian yang lebih baik, pendekatan untuk penanganan kesalahan, dan pemisahan domain/akses data.
Contoh Dunia Nyata
Saya ingin masuk ke dunia Generik bukan dengan menjelaskan apa itu Generik, melainkan dengan memberikan contoh intuitif mengapa mereka berguna. Misalkan Anda telah ditugaskan untuk membuat daftar dinamis yang kaya fitur. Anda bisa menyebutnya array, ArrayList
, List
, a std::vector
, atau apa pun, tergantung pada latar belakang bahasa Anda. Mungkin struktur data ini juga harus memiliki sistem buffer bawaan atau yang dapat ditukar (seperti opsi penyisipan buffer melingkar). Ini akan menjadi pembungkus di sekitar larik JavaScript normal sehingga kita dapat bekerja dengan struktur kita alih-alih array biasa.
Masalah langsung yang akan Anda temui adalah kendala yang diberlakukan oleh sistem tipe. Anda tidak dapat, pada titik ini, menerima tipe apa pun yang Anda inginkan ke dalam fungsi atau metode dengan cara yang bersih (kami akan meninjau kembali pernyataan ini nanti).
Satu-satunya solusi yang jelas adalah mereplikasi struktur data kami untuk semua jenis yang berbeda:
const intList = IntegerList.create(); intList.add(4); const stringList = StringList.create(); stringList.add('hello'); const userList = UserList.create(); userList.add(new User('Jamie'));
.create()
di sini mungkin terlihat arbitrer, dan memang, new SomethingList()
akan lebih mudah, tetapi Anda akan melihat mengapa kami menggunakan metode pabrik statis ini nanti. Secara internal, metode create
memanggil konstruktor.
Ini mengerikan. Kami memiliki banyak logika dalam struktur koleksi ini, dan kami secara terang-terangan menduplikasinya untuk mendukung kasus penggunaan yang berbeda, sepenuhnya melanggar Prinsip KERING dalam prosesnya. Ketika kami memutuskan untuk mengubah implementasi kami, kami harus secara manual menyebarkan/mencerminkan perubahan tersebut di semua struktur dan tipe yang kami dukung, termasuk tipe yang ditentukan pengguna, seperti pada contoh terakhir di atas. Misalkan struktur koleksi itu sendiri panjangnya 100 baris — akan menjadi mimpi buruk untuk mempertahankan beberapa implementasi berbeda di mana satu-satunya perbedaan di antara mereka adalah jenisnya.
Solusi langsung yang mungkin muncul di benak Anda, terutama jika Anda memiliki pola pikir OOP, adalah mempertimbangkan root "supertype" jika Anda mau. Dalam C#, misalnya, ada tipe dengan nama object
, dan object
adalah alias untuk kelas System.Object
. Dalam sistem tipe C#, semua tipe, baik yang telah ditentukan sebelumnya atau yang ditentukan pengguna dan apakah tipe referensi atau tipe nilai, mewarisi baik secara langsung maupun tidak langsung dari System.Object
. Ini berarti bahwa nilai apa pun dapat ditetapkan ke variabel object
tipe (tanpa masuk ke semantik stack/heap dan boxing/unboxing).
Dalam hal ini, masalah kami tampaknya terpecahkan. Kita bisa menggunakan tipe seperti any
dan itu akan memungkinkan kita untuk menyimpan apapun yang kita inginkan dalam koleksi kita tanpa harus menduplikasi strukturnya, dan memang, itu sangat benar:
const intList = AnyList.create(); intList.add(4); const stringList = AnyList.create(); stringList.add('hello'); const userList = AnyList.create(); userList.add(new User('Jamie'));
Mari kita lihat implementasi sebenarnya dari daftar kita menggunakan 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. // ... }
Semua metode relatif sederhana, tetapi kita akan mulai dengan konstruktor. Visibilitasnya bersifat pribadi, karena kami akan menganggap bahwa daftar kami rumit dan kami ingin melarang konstruksi sewenang-wenang. Kami juga mungkin ingin melakukan logika sebelum konstruksi, jadi untuk alasan ini, dan untuk menjaga konstruktor tetap murni, kami mendelegasikan masalah ini ke metode pabrik/pembantu statis, yang dianggap sebagai praktik yang baik.
Metode statis from
dan create
disediakan. Metode from
menerima larik nilai, menjalankan logika kustom, lalu menggunakannya untuk menyusun daftar. Metode create
static mengambil array nilai opsional jika kita ingin menyemai daftar kita dengan data awal. “Operator penggabungan nol” ( ??
) digunakan untuk membuat daftar dengan larik kosong jika tidak ada. Jika sisi kiri operan adalah null
atau undefined
, kita akan kembali ke sisi kanan, karena dalam kasus ini, values
adalah opsional, dan dengan demikian mungkin undefined
. Anda dapat mempelajari lebih lanjut tentang penggabungan nullish di halaman dokumentasi TypeScript yang relevan.
Saya juga telah menambahkan metode select
dan where
. Metode ini hanya membungkus map
dan filter
JavaScript masing-masing. select
memungkinkan kita untuk memproyeksikan array elemen ke dalam bentuk baru berdasarkan fungsi pemilih yang disediakan, dan where
memungkinkan kita untuk memfilter elemen tertentu berdasarkan fungsi predikat yang disediakan. Metode toArray
hanya mengonversi daftar menjadi array dengan mengembalikan referensi array yang kita simpan secara internal.
Terakhir, anggaplah kelas User
berisi metode getName
yang mengembalikan nama dan juga menerima nama sebagai argumen konstruktor pertama dan satu-satunya.
Catatan: Beberapa pembaca akan mengenaliWhere
andSelect
dari C#'s LINQ, tetapi perlu diingat, saya mencoba untuk membuatnya tetap sederhana, jadi saya tidak khawatir tentang kemalasan atau eksekusi yang ditangguhkan. Itu adalah optimasi yang bisa dan harus dilakukan dalam kehidupan nyata.
Selanjutnya, sebagai catatan yang menarik, saya ingin membahas tentang pengertian “predikat”. Dalam Matematika Diskrit dan Logika Proposisional, kita memiliki konsep “proposisi”. Proposisi adalah beberapa pernyataan yang dapat dianggap benar atau salah, seperti “empat habis dibagi dua”. “Predikat” adalah proposisi yang mengandung satu atau lebih variabel, sehingga kebenaran proposisi bergantung pada variabel tersebut. Anda dapat memikirkannya seperti fungsi, sepertiP(x) = x is divisible by two
, karena kita perlu mengetahui nilaix
untuk menentukan apakah pernyataan itu benar atau salah. Anda dapat mempelajari lebih lanjut tentang logika predikat di sini.
Ada beberapa masalah yang akan muncul dari penggunaan any
. Kompiler TypeScript tidak tahu apa-apa tentang elemen di dalam daftar/array internal, sehingga tidak akan memberikan bantuan apa pun di dalam where
atau select
atau saat menambahkan elemen:
// 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);
Karena TypeScript hanya tahu bahwa jenis semua elemen array adalah any
, itu tidak dapat membantu kami pada waktu kompilasi dengan properti yang tidak ada atau fungsi getNames
yang bahkan tidak ada, sehingga kode ini akan menghasilkan beberapa kesalahan runtime yang tidak terduga .
Sejujurnya, segalanya mulai terlihat sangat suram. Kami mencoba menerapkan struktur data kami untuk setiap jenis beton yang ingin kami dukung, tetapi kami segera menyadari bahwa itu sama sekali tidak dapat dipertahankan. Kemudian, kami pikir kami mencapai suatu tempat dengan menggunakan any
, yang analog dengan bergantung pada supertipe root dalam rantai pewarisan dari mana semua tipe berasal, tetapi kami menyimpulkan bahwa kami kehilangan keamanan tipe dengan metode itu. Lalu apa solusinya?
Ternyata, di awal artikel, saya berbohong (semacam):
"Anda tidak dapat, pada titik ini, menerima tipe apa pun yang Anda inginkan menjadi fungsi atau metode dengan cara yang bersih dan bagus."
Anda benar-benar bisa, dan di situlah Generik masuk. Perhatikan saya mengatakan "pada titik ini", karena saya berasumsi kita tidak tahu tentang Generik pada saat itu dalam artikel.
Saya akan mulai dengan menunjukkan implementasi penuh dari struktur Daftar kami dengan Generics, dan kemudian kami akan mundur selangkah, mendiskusikan apa sebenarnya mereka, dan menentukan sintaksnya secara lebih formal. Saya menamakannya TypedList
untuk membedakan dari AnyList
kami sebelumnya:
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. // .. }
Mari kita coba membuat kesalahan yang sama seperti sebelumnya sekali lagi:
// 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)
Seperti yang Anda lihat, kompiler TypeScript secara aktif membantu kami dengan keamanan tipe. Semua komentar itu adalah kesalahan yang saya terima dari kompiler ketika mencoba mengkompilasi kode ini. Generik telah mengizinkan kami untuk menentukan jenis yang kami ingin izinkan untuk dioperasikan oleh daftar kami, dan dari situ, TypeScript dapat memberi tahu jenis segalanya, sampai ke properti objek individual di dalam array.
Jenis yang kami sediakan bisa sesederhana atau serumit yang kami inginkan. Di sini, Anda dapat melihat bahwa kami dapat melewati antarmuka primitif dan kompleks. Kami juga bisa melewatkan array lain, atau kelas, atau apa pun:
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() }));
Penggunaan khusus T
dan U
dan <T>
dan <U>
dalam TypedList<T>
adalah contoh dari Generics yang sedang beraksi. Setelah memenuhi arahan kami untuk membangun struktur koleksi yang aman untuk tipe, kami akan meninggalkan contoh ini untuk saat ini, dan kami akan kembali ke sana setelah kami memahami apa sebenarnya Generik, cara kerjanya, dan sintaksnya. Ketika saya mempelajari konsep baru, saya selalu suka memulai dengan melihat contoh kompleks dari konsep yang digunakan, sehingga ketika saya mulai mempelajari dasar-dasarnya, saya dapat membuat hubungan antara topik dasar dan contoh yang ada yang saya miliki di kepala.
Apa itu Generik?
Cara sederhana untuk memahami Generik adalah dengan menganggapnya sebagai relatif analog dengan placeholder atau variabel tetapi untuk tipe. Itu tidak berarti bahwa Anda dapat melakukan operasi yang sama pada placeholder tipe generik seperti halnya variabel, tetapi variabel tipe generik dapat dianggap sebagai beberapa placeholder yang mewakili tipe konkret yang akan digunakan di masa mendatang. Artinya, menggunakan Generik adalah metode penulisan program dalam bentuk jenis yang akan ditentukan di lain waktu. Alasan mengapa ini berguna adalah karena memungkinkan kita untuk membangun struktur data yang dapat digunakan kembali di berbagai tipe tempat mereka beroperasi (atau agnostik tipe).
Itu bukan penjelasan terbaik, jadi untuk membuatnya lebih sederhana, seperti yang telah kita lihat, adalah umum dalam pemrograman bahwa kita mungkin perlu membangun fungsi/kelas/struktur data yang akan beroperasi pada tipe tertentu, tetapi sama-sama umum bahwa struktur data seperti itu perlu bekerja di berbagai jenis yang berbeda juga. Jika kita terjebak dalam posisi di mana kita harus secara statis mendeklarasikan tipe beton di mana struktur data akan beroperasi pada saat kita mendesain struktur data (pada waktu kompilasi), kita akan segera menemukan bahwa kita perlu membangunnya kembali. struktur dengan cara yang hampir sama persis untuk setiap tipe yang ingin kita dukung, seperti yang kita lihat pada contoh di atas.
Generik membantu kami memecahkan masalah ini dengan mengizinkan kami untuk menunda persyaratan untuk jenis beton sampai benar-benar diketahui.
Generik Dalam TypeScript
Kami sekarang memiliki sedikit ide organik mengapa Generik berguna dan kami telah melihat contoh yang agak rumit dalam praktiknya. Untuk sebagian besar, TypedList<T>
mungkin sudah sangat masuk akal, terutama jika Anda berasal dari latar belakang bahasa yang diketik secara statis, tetapi saya ingat mengalami kesulitan memahami konsep ketika saya pertama kali belajar, jadi saya ingin membangun contoh itu dengan memulai dengan fungsi sederhana. Konsep yang terkait dengan abstraksi dalam perangkat lunak dapat menjadi sangat sulit untuk diinternalisasi, jadi jika gagasan Generik belum cukup diklik, itu sepenuhnya baik-baik saja, dan mudah-mudahan, dengan penutupan artikel ini, idenya setidaknya akan sedikit intuitif.
Untuk membangun untuk dapat memahami contoh itu, mari kita bekerja dari fungsi sederhana. Kita akan mulai dengan "Fungsi Identitas", yang sebagian besar artikel, termasuk dokumentasi TypeScript itu sendiri, suka menggunakan.
"Fungsi Identitas", dalam matematika, adalah fungsi yang memetakan inputnya langsung ke outputnya, seperti f(x) = x
. Apa yang Anda masukkan adalah apa yang Anda keluarkan. Kami dapat menyatakan bahwa, dalam JavaScript, sebagai:
function identity(input) { return input; }
Atau, lebih singkatnya:
const identity = input => input;
Mencoba mem-port ini ke TypeScript membawa kembali masalah sistem tipe yang sama yang kita lihat sebelumnya. Solusinya adalah mengetik dengan any
, yang kami tahu jarang merupakan ide yang bagus, menduplikasi/membebani fungsi untuk setiap jenis (merusak KERING), atau menggunakan Generics.
Dengan opsi terakhir, kita dapat merepresentasikan fungsinya sebagai berikut:
// 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
Sintaks <T>
di sini mendeklarasikan fungsi ini sebagai Generik. Sama seperti fungsi yang memungkinkan kita untuk melewatkan parameter input arbitrer ke dalam daftar argumennya, dengan fungsi Generik, kita juga dapat melewatkan parameter tipe arbitrer.
Bagian <T>
dari tanda tangan identity<T>(input: T): T
dan <T>(input: T): T
dalam kedua kasus menyatakan bahwa fungsi yang dimaksud akan menerima satu parameter tipe generik bernama T
. Sama seperti bagaimana variabel dapat berupa nama apa pun, begitu juga placeholder Generik kami, tetapi ini adalah konvensi untuk menggunakan huruf kapital "T" ("T" untuk "Jenis") dan untuk menurunkan alfabet sesuai kebutuhan. Ingat, T
adalah tipe, jadi kami juga menyatakan bahwa kami akan menerima satu argumen fungsi dari input
nama dengan tipe T
dan fungsi kami akan mengembalikan tipe T
. Hanya itu yang dikatakan tanda tangan. Coba biarkan T = string
di kepala Anda — ganti semua T
s dengan string
di tanda tangan tersebut. Lihat bagaimana semua keajaiban itu tidak terjadi? Lihat betapa miripnya dengan cara non-generik Anda menggunakan fungsi setiap hari?
Ingatlah apa yang sudah Anda ketahui tentang TypeScript dan tanda tangan fungsi. Yang kami katakan adalah bahwa T
adalah tipe arbitrer yang akan diberikan pengguna saat memanggil fungsi, sama seperti input
adalah nilai arbitrer yang akan diberikan pengguna saat memanggil fungsi. Dalam hal ini, input
harus apa pun tipe T
itu saat fungsi dipanggil di masa mendatang .
Selanjutnya, di "masa depan", dalam dua pernyataan log, kami "menyerahkan" tipe konkret yang ingin kami gunakan, seperti halnya kami melakukan variabel. Perhatikan peralihan dalam verbiage di sini — dalam bentuk awal <T> signature
, saat mendeklarasikan fungsi kita, itu adalah generik — yaitu, ia bekerja pada tipe generik, atau tipe yang akan ditentukan nanti. Itu karena kita tidak tahu tipe apa yang ingin digunakan pemanggil ketika kita benar-benar menulis fungsi. Tetapi, ketika pemanggil memanggil fungsi, dia tahu persis tipe apa yang ingin mereka kerjakan, yang berupa string
dan number
dalam kasus ini.
Anda dapat membayangkan gagasan memiliki fungsi log yang dideklarasikan dengan cara ini di perpustakaan pihak ketiga — penulis perpustakaan tidak tahu jenis apa yang ingin digunakan oleh pengembang yang menggunakan lib, jadi mereka membuat fungsi tersebut generik, pada dasarnya menunda kebutuhan untuk jenis beton sampai benar-benar diketahui.
Saya ingin menekankan bahwa Anda harus memikirkan proses ini dengan cara yang sama seperti Anda melakukan gagasan untuk meneruskan variabel ke suatu fungsi untuk tujuan mendapatkan pemahaman yang lebih intuitif. Yang kita lakukan sekarang adalah melewatkan tipe juga.
Pada titik di mana kita memanggil fungsi dengan parameter number
, tanda tangan asli, untuk semua maksud dan tujuan, dapat dianggap sebagai identity(input: number): number
. Dan, pada titik di mana kita memanggil fungsi dengan parameter string
, sekali lagi, tanda tangan asli mungkin juga adalah identity(input: string): string
. Anda dapat membayangkan bahwa, saat melakukan panggilan, setiap T
generik diganti dengan tipe konkret yang Anda berikan pada saat itu.
Menjelajahi Sintaks Generik
Ada sintaks dan semantik yang berbeda untuk menentukan obat generik dalam konteks Fungsi ES5, Fungsi Panah, Alias Jenis, Antarmuka, dan Kelas. Kami akan mengeksplorasi perbedaan tersebut di bagian ini.
Menjelajahi Sintaks Generik — Fungsi
Anda telah melihat beberapa contoh fungsi generik sekarang, tetapi penting untuk dicatat bahwa fungsi generik dapat menerima lebih dari satu parameter tipe generik, seperti halnya variabel. Anda dapat memilih untuk meminta satu, atau dua, atau tiga, atau berapa pun jenis yang Anda inginkan, semuanya dipisahkan dengan koma (sekali lagi, seperti argumen input).
Fungsi ini menerima tiga jenis input dan secara acak mengembalikan salah satunya:
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 );
Anda juga dapat melihat bahwa sintaksnya sedikit berbeda tergantung pada apakah kita menggunakan Fungsi ES5 atau Fungsi Panah, tetapi keduanya mendeklarasikan parameter tipe dalam tanda tangan:
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]; }
Ingatlah bahwa tidak ada "batasan keunikan" yang dipaksakan pada tipe — Anda dapat memasukkan kombinasi apa pun yang Anda inginkan, seperti dua string
s dan number
, misalnya. Selain itu, sama seperti argumen input yang "dalam cakupan" untuk isi fungsi, demikian juga parameter tipe generik. Contoh sebelumnya menunjukkan bahwa kita memiliki akses penuh ke T
, U
, dan V
dari dalam tubuh fungsi, dan kita menggunakannya untuk mendeklarasikan 3-tuple lokal.
Anda dapat membayangkan bahwa obat generik ini beroperasi pada "konteks" tertentu atau dalam "seumur hidup" tertentu, dan itu tergantung di mana mereka dideklarasikan. Generik pada fungsi berada dalam cakupan di dalam tanda tangan dan isi fungsi (dan penutupan yang dibuat oleh fungsi bersarang), sedangkan generik yang dideklarasikan pada kelas atau antarmuka atau alias tipe berada dalam cakupan untuk semua anggota kelas atau antarmuka atau alias tipe.
Gagasan umum tentang fungsi tidak terbatas pada "fungsi bebas" atau "fungsi mengambang" (fungsi tidak melekat pada objek atau kelas, istilah C++), tetapi mereka juga dapat digunakan pada fungsi yang melekat pada struktur lain juga.
Kita bisa menempatkan randomValue
itu di sebuah kelas dan kita bisa menyebutnya sama saja:
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 => { // ... } }
Kami juga dapat menempatkan definisi di dalam antarmuka:
interface IUtils { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }
Atau dalam alias tipe:
type Utils = { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }
Sama seperti sebelumnya, parameter tipe generik ini "dalam cakupan" untuk fungsi tertentu — mereka bukan kelas, atau antarmuka, atau tipe alias-lebar. Mereka hidup hanya dalam fungsi tertentu yang menjadi dasar mereka. Untuk berbagi tipe generik di semua anggota struktur, Anda harus membubuhi keterangan nama struktur itu sendiri, seperti yang akan kita lihat di bawah.
Menjelajahi Sintaks Generik — Ketik Alias
Dengan Type Aliases, sintaks generik digunakan pada nama alias.
Misalnya, beberapa fungsi "tindakan" yang menerima nilai, mungkin mengubah nilai itu, tetapi mengembalikan void dapat ditulis sebagai:
type Action<T> = (val: T) => void;
Catatan : Ini seharusnya familiar bagi pengembang C# yang memahami delegasi Action<T>.
Atau, fungsi panggilan balik yang menerima kesalahan dan nilai dapat dideklarasikan sebagai berikut:
type CallbackFunction<T> = (err: Error, data: T) => void; const usersApi = { get(uri: string, cb: CallbackFunction<User>) { /// ... } }
Dengan pengetahuan kita tentang fungsi generik, kita bisa melangkah lebih jauh dan membuat fungsi pada objek API generik juga:
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>) { /// ... } }
Sekarang, kita mengatakan bahwa fungsi get
menerima beberapa parameter tipe generik, dan apa pun itu, CallbackFunction
menerimanya. Kami pada dasarnya telah "melewati" T
yang masuk ke get
sebagai T
untuk CallbackFunction
. Mungkin ini akan lebih masuk akal jika kita mengubah nama:
type CallbackFunction<TData> = (err: Error, data: TData) => void; const api = { get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) { // ... } }
Awalan tipe params dengan T
hanyalah sebuah konvensi, sama seperti awalan antarmuka dengan I
atau variabel anggota dengan _
. Apa yang dapat Anda lihat di sini adalah bahwa CallbackFunction
menerima beberapa tipe ( TData
) yang mewakili muatan data yang tersedia untuk fungsi tersebut, sementara get
menerima parameter tipe yang mewakili tipe/bentuk data Respons HTTP ( TResponse
). Klien HTTP ( api
), mirip dengan Axios, menggunakan apa pun TResponse
itu sebagai TData
untuk CallbackFunction
. Ini memungkinkan pemanggil API untuk memilih tipe data yang akan mereka terima kembali dari API (misalkan di tempat lain dalam pipa kami memiliki middleware yang mem-parsing JSON menjadi DTO).
Jika kami ingin mengambil ini lebih jauh, kami dapat memodifikasi parameter tipe generik di CallbackFunction
untuk menerima tipe kesalahan khusus juga:
type CallbackFunction<TData, TError> = (err: TError, data: TData) => void;
Dan, sama seperti Anda bisa membuat argumen fungsi opsional, Anda juga bisa dengan parameter tipe. Jika pengguna tidak memberikan jenis kesalahan, kami akan menyetelnya ke konstruktor kesalahan secara default:
type CallbackFunction<TData, TError = Error> = (err: TError, data: TData) => void;
Dengan ini, kita sekarang dapat menentukan tipe fungsi panggilan balik dalam beberapa cara:
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) => { // ... });
Gagasan tentang parameter default ini dapat diterima di seluruh fungsi, kelas, antarmuka, dan sebagainya — tidak hanya terbatas pada alias tipe. Dalam semua contoh yang telah kita lihat sejauh ini, kita dapat menetapkan parameter tipe apa pun yang kita inginkan ke nilai default. Ketik Alias , seperti halnya fungsi, dapat mengambil sebanyak mungkin parameter tipe umum yang Anda inginkan.
Menjelajahi Sintaks Generik — Antarmuka
Seperti yang Anda lihat, parameter tipe generik dapat diberikan ke fungsi pada antarmuka:
interface IUselessFunctions { // Not generic printHelloWorld(); // Generic identity<T>(t: T): T; }
Dalam hal ini, T
hanya hidup untuk fungsi identity
sebagai input dan tipe kembaliannya.
Kami juga dapat membuat parameter tipe tersedia untuk semua anggota antarmuka, seperti halnya dengan kelas dan alias tipe, dengan menetapkan bahwa antarmuka itu sendiri menerima generik. Kita akan berbicara tentang Pola Repositori sedikit kemudian ketika kita membahas kasus penggunaan yang lebih kompleks untuk obat generik, jadi tidak apa-apa jika Anda belum pernah mendengarnya. Pola Repositori memungkinkan kita untuk mengabstraksi penyimpanan data kita untuk membuat logika bisnis agnostik kegigihan. Jika Anda ingin membuat antarmuka repositori generik yang beroperasi pada tipe entitas yang tidak dikenal, kita dapat mengetiknya sebagai berikut:
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>; }
Catatan : Ada banyak pemikiran berbeda seputar Repositori, dari definisi Martin Fowler hingga definisi Agregat DDD. Saya hanya mencoba menunjukkan kasus penggunaan untuk obat generik, jadi saya tidak terlalu peduli dengan penerapan yang sepenuhnya benar. Pasti ada sesuatu yang bisa dikatakan untuk tidak menggunakan repositori generik, tetapi kita akan membicarakannya nanti.
Seperti yang Anda lihat di sini, IRepository
adalah antarmuka yang berisi metode untuk menyimpan dan mengambil data. Ini beroperasi pada beberapa parameter tipe generik bernama T
, dan T
digunakan sebagai input untuk add
dan findById
updateById
Ingatlah bahwa ada perbedaan yang sangat besar antara menerima parameter tipe generik pada nama antarmuka dibandingkan dengan mengizinkan setiap fungsi itu sendiri untuk menerima parameter tipe generik. Yang pertama, seperti yang telah kita lakukan di sini, memastikan bahwa setiap fungsi dalam antarmuka beroperasi pada tipe yang sama T
. Artinya, untuk IRepository<User>
, setiap metode yang menggunakan T
di antarmuka sekarang bekerja pada objek User
. Dengan metode yang terakhir, setiap fungsi akan diizinkan untuk bekerja dengan jenis apa pun yang diinginkannya. Akan sangat aneh jika hanya dapat menambahkan User
s ke Repositori tetapi dapat menerima Policies
atau Orders
kembali, misalnya, yang merupakan situasi potensial yang akan kita hadapi jika kita tidak dapat menegakkan bahwa jenisnya adalah seragam di semua metode.
Antarmuka yang diberikan tidak hanya dapat berisi tipe bersama, tetapi juga tipe unik untuk anggotanya. Misalnya, jika kita ingin meniru sebuah array, kita bisa mengetikkan antarmuka seperti ini:
interface IArray<T> { forEach(func: (elem: T, index: number) => void): this; map<U>(func: (elem: T, index: number) => U): IArray<U>; }
Dalam hal ini, baik forEach
dan map
memiliki akses ke T
dari nama antarmuka. Seperti yang dinyatakan, Anda dapat membayangkan bahwa T
berada dalam cakupan untuk semua anggota antarmuka. Meskipun demikian, tidak ada yang menghentikan fungsi individu di dalam untuk menerima parameter tipe mereka sendiri juga. Fungsi map
tidak, dengan U
. Sekarang, map
memiliki akses ke T
dan U
. Kami harus menamai parameter dengan huruf yang berbeda, seperti U
, karena T
sudah diambil dan kami tidak ingin tabrakan penamaan. Sama seperti namanya, map
akan "memetakan" elemen tipe T
di dalam array ke elemen baru tipe U
. Ini memetakan T
s ke U
s. Nilai kembalian dari fungsi ini adalah antarmuka itu sendiri, sekarang beroperasi pada tipe baru U
, sehingga kita agak bisa meniru sintaks JavaScript yang dapat dirantai untuk array.
Kita akan melihat contoh kekuatan Generik dan Antarmuka segera saat kita mengimplementasikan Pola Repositori dan membahas Injeksi Ketergantungan. Sekali lagi, kami dapat menerima sebanyak mungkin parameter generik serta memilih satu atau lebih parameter default yang ditumpuk di akhir antarmuka.
Menjelajahi Sintaks Generik — Kelas
Sama seperti kita bisa meneruskan parameter tipe generik ke alias tipe, fungsi, atau antarmuka, kita juga bisa meneruskan satu atau lebih ke kelas. Setelah melakukannya, parameter tipe itu akan dapat diakses oleh semua anggota kelas itu serta kelas dasar yang diperluas atau antarmuka yang diimplementasikan.
Mari kita buat kelas koleksi lain, tetapi sedikit lebih sederhana daripada TypedList
di atas, sehingga kita dapat melihat interop antara tipe generik, antarmuka, dan anggota. Kita akan melihat contoh penerusan tipe ke kelas dasar dan pewarisan antarmuka nanti.
Koleksi kami hanya akan mendukung fungsi CRUD dasar selain map
dan metode forEach
.
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);
Mari kita bahas apa yang terjadi di sini. Kelas Collection
menerima satu parameter tipe generik bernama T
. Tipe itu menjadi dapat diakses oleh semua anggota kelas. Kami menggunakannya untuk mendefinisikan sebuah array pribadi bertipe T[]
, yang juga dapat kami nyatakan dalam bentuk Array<T>
(Lihat? Generik lagi untuk pengetikan array TS normal). Lebih lanjut, sebagian besar fungsi anggota menggunakan T
itu dalam beberapa cara, seperti dengan mengontrol jenis yang ditambahkan dan dihapus atau memeriksa apakah koleksi berisi elemen.
Terakhir, seperti yang telah kita lihat sebelumnya, metode map
membutuhkan parameter tipe generiknya sendiri. 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());
Untuk mendemonstrasikan inferensi tipe, saya telah menghapus semua anotasi tipe yang secara teknis asing dari struktur TypedList
kami sebelumnya, dan Anda dapat melihat, dari gambar di bawah, bahwa TSC masih menyimpulkan semua tipe dengan benar:
TypedList
tanpa deklarasi tipe asing:
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. // .. }
Berdasarkan nilai pengembalian fungsi dan berdasarkan jenis input yang diteruskan from
dan konstruktor, TSC memahami semua jenis informasi. Pada gambar di bawah, saya telah menggabungkan beberapa gambar yang menunjukkan Ekstensi Bahasa Kode TypeScript Visual Studio (dan dengan demikian kompiler) menyimpulkan semua jenis:
Batasan Umum
Terkadang, kami ingin menempatkan batasan di sekitar tipe generik. Mungkin kami tidak dapat mendukung setiap jenis yang ada, tetapi kami dapat mendukung sebagian dari mereka. Katakanlah kita ingin membangun fungsi yang mengembalikan panjang beberapa koleksi. Seperti yang terlihat di atas, kita dapat memiliki banyak jenis array/koleksi yang berbeda, dari Array
JavaScript default hingga yang kustom. Bagaimana kita memberi tahu fungsi kita bahwa beberapa tipe generik memiliki properti length
yang melekat padanya? Demikian pula, bagaimana cara membatasi tipe konkret yang kita berikan ke fungsi ke tipe yang berisi data yang kita butuhkan? Contoh seperti ini, misalnya, tidak akan berfungsi:
function getLength<T>(collection: T): number { // Error. TS does not know that a type T contains a `length` property. return collection.length; }
Jawabannya adalah dengan memanfaatkan Kendala Generik. Kita dapat mendefinisikan sebuah antarmuka yang menjelaskan properti yang kita butuhkan:
interface IHasLength { length: number; }
Sekarang, ketika mendefinisikan fungsi generik kita, kita dapat membatasi tipe generik menjadi salah satu yang memperluas antarmuka itu:
function getLength<T extends IHasLength>(collection: T): number { // Restricting `collection` to be a type that contains // everything within the `IHasLength` interface. return collection.length; }
Contoh Dunia Nyata
Dalam beberapa bagian berikutnya, kita akan membahas beberapa contoh generik dunia nyata yang membuat kode yang lebih elegan dan mudah dipahami. Kita telah melihat banyak contoh sepele, tetapi saya ingin membahas beberapa pendekatan untuk penanganan kesalahan, pola akses data, dan state/props React front-end.
Contoh Dunia Nyata — Pendekatan Penanganan Kesalahan
JavaScript berisi mekanisme kelas satu untuk menangani kesalahan, seperti kebanyakan bahasa pemrograman — try
/ catch
. Meskipun demikian, saya bukan penggemar berat tampilannya saat digunakan. Itu tidak berarti saya tidak menggunakan mekanisme, saya melakukannya, tetapi saya cenderung mencoba dan menyembunyikannya sebanyak yang saya bisa. Dengan mengabstraksi try
/ catch
away, saya juga dapat menggunakan kembali logika penanganan kesalahan di seluruh operasi yang mungkin gagal.
Misalkan kita sedang membangun beberapa Data Access Layer. Ini adalah lapisan aplikasi yang membungkus logika persistensi untuk menangani metode penyimpanan data. Jika kami melakukan operasi basis data, dan jika basis data tersebut digunakan di seluruh jaringan, kesalahan khusus DB tertentu dan pengecualian sementara kemungkinan akan terjadi. Bagian dari alasan memiliki Lapisan Akses Data khusus adalah untuk mengabstraksi database dari logika bisnis. Karena itu, kami tidak dapat memiliki kesalahan khusus DB seperti itu yang dilemparkan ke atas tumpukan dan keluar dari lapisan ini. Kita harus membungkusnya terlebih dahulu.
Mari kita lihat implementasi tipikal yang akan menggunakan 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()); } } }
Beralih ke true
hanyalah metode untuk dapat menggunakan pernyataan case
sakelar untuk logika pemeriksaan kesalahan saya daripada harus mendeklarasikan rantai if/else if — trik yang pertama kali saya dengar dari @Jeffijoe.
Jika kita memiliki beberapa fungsi seperti itu, kita harus mereplikasi logika pembungkus kesalahan ini, yang merupakan praktik yang sangat buruk. Kelihatannya cukup bagus untuk satu fungsi, tetapi akan menjadi mimpi buruk bagi banyak fungsi. Untuk mengabstraksi logika ini, kita dapat membungkusnya dalam fungsi penanganan kesalahan khusus yang akan melewati hasilnya, tetapi menangkap dan membungkus kesalahan apa pun jika kesalahan itu terjadi:
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()); } } }
Untuk memastikan ini masuk akal, kami memiliki fungsi yang berjudul withErrorHandling
yang menerima beberapa parameter tipe generik T
. T
ini mewakili jenis nilai resolusi sukses dari janji yang kami harapkan dikembalikan dari fungsi panggilan balik dalOperation
. Biasanya, karena kita baru saja mengembalikan hasil kembali dari fungsi async dalOperation
, kita tidak perlu await
karena itu akan membungkus fungsi dalam janji asing kedua, dan kita bisa membiarkan await
ke kode panggilan. Dalam hal ini, kita perlu menangkap kesalahan apa pun, jadi await
diperlukan.
Kita sekarang dapat menggunakan fungsi ini untuk membungkus operasi DAL kita dari sebelumnya:
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); }); }
Dan di sana kita pergi. Kami memiliki fungsi permintaan pengguna fungsi tipe-safe dan error-safe.
Selain itu, seperti yang Anda lihat sebelumnya, jika TypeScript Compiler memiliki informasi yang cukup untuk menyimpulkan tipe secara implisit, Anda tidak harus meneruskannya secara eksplisit. Dalam hal ini, TSC mengetahui bahwa hasil pengembalian dari fungsi tersebut adalah tipe generiknya. Jadi, jika mapper.toDomain(user)
mengembalikan tipe User
, Anda tidak perlu memasukkan tipe sama sekali:
async function queryUser(userID: string) { return withErrorHandling(async () => { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(user); }); }
Pendekatan lain untuk penanganan kesalahan yang cenderung saya sukai adalah Tipe Monadik. The Either Monad adalah tipe data aljabar dari bentuk Either<T, U>
, di mana T
dapat mewakili tipe kesalahan, dan U
dapat mewakili tipe kegagalan. Menggunakan Monadic Types mendengarkan pemrograman fungsional, dan manfaat utamanya adalah kesalahan menjadi tipe-safe — tanda tangan fungsi normal tidak memberi tahu pemanggil API apa pun tentang kesalahan apa yang mungkin ditimbulkan oleh fungsi tersebut. Misalkan kita melempar kesalahan NotFound
dari dalam queryUser
. Tanda tangan queryUser(userID: string): Promise<User>
tidak memberi tahu kami apa pun tentang itu. Tapi, tanda tangan seperti queryUser(userID: string): Promise<Either<NotFound, User>>
benar-benar bisa. Saya tidak akan menjelaskan cara kerja monad seperti Either Monad dalam artikel ini karena mereka bisa sangat kompleks, dan ada berbagai metode yang harus mereka pertimbangkan untuk dianggap monadik, seperti pemetaan/penjilidan. Jika Anda ingin mempelajari lebih lanjut tentang mereka, saya akan merekomendasikan dua pembicaraan NDC Scott Wlaschin, di sini dan di sini, serta pembicaraan Daniel Chamber di sini. Situs ini juga posting blog ini mungkin berguna juga.
Contoh Dunia Nyata — Pola Repositori
Mari kita lihat kasus penggunaan lain di mana Generics mungkin bisa membantu. Sebagian besar sistem back-end diperlukan untuk berinteraksi dengan database dalam beberapa cara — ini bisa berupa database relasional seperti PostgreSQL, database dokumen seperti MongoDB, atau bahkan database grafik, seperti Neo4j.
Karena, sebagai pengembang, kita harus bertujuan untuk desain yang digabungkan dan sangat kohesif, itu akan menjadi argumen yang adil untuk mempertimbangkan apa konsekuensi dari migrasi sistem database. Juga adil untuk mempertimbangkan bahwa kebutuhan akses data yang berbeda mungkin lebih menyukai pendekatan akses data yang berbeda (ini mulai masuk ke CQRS sedikit, yang merupakan pola untuk memisahkan membaca dan menulis. Lihat posting Martin Fowler dan daftar MSDN jika Anda mau untuk mempelajari lebih lanjut Buku “Implementing Domain Driven Design” oleh Vaughn Vernon dan “Patterns, Principles, and Practices of Domain-Driven Design” oleh Scott Millet juga bagus untuk dibaca). Kami juga harus mempertimbangkan pengujian otomatis. Sebagian besar tutorial yang menjelaskan pembangunan sistem back-end dengan Node.js memadukan kode akses data dengan logika bisnis dengan perutean. Artinya, mereka cenderung menggunakan MongoDB dengan ODM Mongoose, mengambil pendekatan Rekaman Aktif, dan tidak memiliki pemisahan masalah yang bersih. Teknik seperti itu tidak disukai dalam aplikasi besar; saat Anda memutuskan ingin memigrasikan satu sistem basis data ke sistem basis data lainnya, atau saat Anda menyadari bahwa Anda lebih menyukai pendekatan akses data yang berbeda, Anda harus menghapus kode akses data lama itu, menggantinya dengan kode baru, dan harap Anda tidak memperkenalkan bug apa pun pada perutean dan logika bisnis di sepanjang jalan.
Tentu, Anda mungkin berpendapat bahwa pengujian unit dan integrasi akan mencegah regresi, tetapi jika pengujian tersebut menemukan dirinya digabungkan dan bergantung pada detail implementasi yang seharusnya agnostik, pengujian tersebut juga kemungkinan akan rusak dalam prosesnya.
Pendekatan umum untuk memecahkan masalah ini adalah Pola Repositori. Dikatakan bahwa untuk memanggil kode, kita harus mengizinkan lapisan akses data kita untuk meniru koleksi objek atau entitas domain dalam memori belaka. Dengan cara ini, kita dapat membiarkan bisnis mendorong desain daripada database (model data). Untuk aplikasi besar, pola arsitektur yang disebut Desain Berbasis Domain menjadi berguna. Repositori, dalam Pola Repositori, adalah komponen, paling umum kelas, yang merangkum dan menyimpan semua logika internal untuk mengakses sumber data. Dengan ini, kami dapat memusatkan kode akses data ke satu lapisan, membuatnya mudah diuji dan dapat digunakan kembali dengan mudah. Selanjutnya, kita dapat menempatkan lapisan pemetaan di antaranya, memungkinkan kita untuk memetakan model domain database-agnostik ke serangkaian pemetaan tabel satu-ke-satu. Setiap fungsi yang tersedia di Repositori secara opsional dapat menggunakan metode akses data yang berbeda jika Anda mau.
Ada banyak pendekatan dan semantik yang berbeda untuk Repositori, Unit Kerja, transaksi database di seluruh tabel, dan sebagainya. Karena ini adalah artikel tentang Generik, saya tidak ingin membahas terlalu banyak, jadi saya akan mengilustrasikan contoh sederhana di sini, tetapi penting untuk dicatat bahwa aplikasi yang berbeda memiliki kebutuhan yang berbeda. Repositori untuk Agregat DDD akan sangat berbeda dari apa yang kita lakukan di sini, misalnya. Bagaimana saya menggambarkan implementasi Repositori di sini bukanlah bagaimana saya mengimplementasikannya dalam proyek nyata, karena ada banyak fungsi yang hilang dan praktik arsitektur yang kurang diinginkan yang digunakan.
Misalkan kita memiliki Users
dan Tasks
sebagai model domain. Ini bisa saja POTO — Objek TypeScript Lama Biasa. Tidak ada gagasan tentang database yang dimasukkan ke dalamnya, oleh karena itu, Anda tidak akan memanggil User.save()
, misalnya, seperti yang Anda lakukan menggunakan Mongoose. Menggunakan Pola Repositori, kami dapat mempertahankan pengguna atau menghapus tugas dari logika bisnis kami sebagai berikut:
// 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);
Jelas, Anda dapat melihat bagaimana semua logika akses data yang berantakan dan sementara tersembunyi di balik fasad/abstraksi repositori ini, membuat logika bisnis agnostik terhadap masalah persistensi.
Mari kita mulai dengan membangun beberapa model domain sederhana. Ini adalah model yang akan berinteraksi dengan kode aplikasi. Mereka anemia di sini tetapi akan memegang logika mereka sendiri untuk memenuhi invarian bisnis di dunia nyata, yaitu, mereka tidak akan menjadi kantong data belaka.
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; } }
Anda akan segera melihat mengapa kami mengekstrak informasi pengetikan identitas ke antarmuka. Metode mendefinisikan model domain dan melewati semuanya melalui konstruktor bukanlah cara saya melakukannya di dunia nyata. Selain itu, mengandalkan kelas model domain abstrak akan lebih disukai daripada antarmuka untuk mendapatkan implementasi id
secara gratis.
Untuk Repositori, karena, dalam kasus ini, kami berharap bahwa banyak mekanisme persistensi yang sama akan dibagikan di seluruh model domain yang berbeda, kami dapat mengabstraksi metode Repositori kami ke antarmuka generik:
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>; }
Kita bisa melangkah lebih jauh dan membuat Generic Repository juga untuk mengurangi duplikasi. Untuk singkatnya, saya tidak akan melakukannya di sini, dan saya harus mencatat bahwa antarmuka Repositori Generik seperti ini dan Repositori Generik, secara umum, cenderung disukai, karena Anda mungkin memiliki entitas tertentu yang hanya-baca, atau tulis -saja, atau yang tidak dapat dihapus, atau serupa. Itu tergantung pada aplikasi. Juga, kami tidak memiliki gagasan tentang "unit kerja" untuk berbagi transaksi di seluruh tabel, fitur yang akan saya terapkan di dunia nyata, tetapi, sekali lagi, karena ini adalah demo kecil, saya tidak ingin terlalu teknis.
Mari kita mulai dengan mengimplementasikan UserRepository
kita. Saya akan mendefinisikan antarmuka IUserRepository
yang menyimpan metode khusus untuk pengguna, sehingga memungkinkan kode panggilan bergantung pada abstraksi itu ketika kita ketergantungan menyuntikkan implementasi konkret:
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. }
Repositori Tugas akan serupa tetapi akan berisi metode yang berbeda sesuai keinginan aplikasi.
Di sini, kami mendefinisikan antarmuka yang memperluas antarmuka generik, jadi kami harus melewati tipe konkret yang sedang kami kerjakan. Seperti yang Anda lihat dari kedua antarmuka, kami memiliki gagasan bahwa kami mengirim model domain POTO ini dan kami mengeluarkannya. Kode panggilan tidak tahu apa mekanisme kegigihan yang mendasarinya, dan itulah intinya.
Pertimbangan selanjutnya yang harus dilakukan adalah bahwa tergantung pada metode akses data yang kita pilih, kita harus menangani kesalahan khusus database. Kita dapat menempatkan Mongoose atau Knex Query Builder di belakang Repositori ini, misalnya, dan dalam hal ini, kita harus menangani kesalahan spesifik itu — kami tidak ingin mereka menggelembungkan logika bisnis karena itu akan memecah pemisahan masalah dan memperkenalkan tingkat yang lebih besar dari kopling.
Mari kita tentukan Basis Repositori untuk metode akses data yang ingin kita gunakan yang dapat menangani kesalahan untuk kita:
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. } } }
Sekarang, kita dapat memperluas Kelas Dasar ini di Repositori dan mengakses metode Generik itu:
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`. }
Perhatikan bahwa fungsi kita mengambil DbUser
dari database dan memetakannya ke model domain User
sebelum mengembalikannya. Ini adalah pola Data Mapper dan sangat penting untuk menjaga pemisahan kekhawatiran. DbUser
adalah pemetaan satu-ke-satu ke tabel database — ini adalah model data tempat Repositori beroperasi — dan dengan demikian sangat bergantung pada teknologi penyimpanan data yang digunakan. Untuk alasan ini, DbUser
s tidak akan pernah meninggalkan Repositori dan akan dipetakan ke model domain User
sebelum dikembalikan. Saya tidak menunjukkan implementasi DbUser
, tetapi itu bisa berupa kelas atau antarmuka sederhana.
Sejauh ini, dengan menggunakan Pola Repositori, yang didukung oleh Generics, kami telah berhasil mengabstraksikan masalah akses data ke dalam unit-unit kecil serta mempertahankan keamanan jenis dan kegunaan ulang.
Terakhir, untuk tujuan Pengujian Unit dan Integrasi, misalkan kita akan menyimpan implementasi repositori dalam memori sehingga dalam lingkungan pengujian, kita dapat menyuntikkan repositori itu, dan melakukan pernyataan berbasis status pada disk daripada mengejek dengan kerangka mengejek. Metode ini memaksa segalanya untuk mengandalkan antarmuka yang menghadap publik daripada mengizinkan pengujian digabungkan ke detail implementasi. Karena satu-satunya perbedaan antara setiap repositori adalah metode yang mereka pilih untuk ditambahkan di bawah antarmuka ISomethingRepository
, kita dapat membangun repositori dalam memori generik dan memperluasnya dalam implementasi khusus tipe:
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. }
Tujuan dari kelas dasar ini adalah untuk melakukan semua logika untuk menangani penyimpanan dalam memori sehingga kita tidak perlu menduplikasinya dalam repositori pengujian dalam memori. Karena metode seperti findById
, repositori ini harus memiliki pemahaman bahwa entitas berisi bidang id
, itulah sebabnya batasan umum pada antarmuka IHasIdentity
diperlukan. Kami melihat antarmuka ini sebelumnya — itulah yang diterapkan oleh model domain kami.
Dengan ini, ketika membangun pengguna dalam memori atau repositori tugas, kita bisa memperluas kelas ini dan mendapatkan sebagian besar metode yang diimplementasikan secara otomatis:
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. }
Di sini, InMemoryRepository
kami perlu mengetahui bahwa entitas memiliki bidang seperti id
dan username
, sehingga kami meneruskan User
sebagai parameter generik. User
sudah mengimplementasikan IHasIdentity
, sehingga batasan generik terpenuhi, dan kami juga menyatakan bahwa kami juga memiliki properti username
.
Sekarang, ketika kita ingin menggunakan repositori ini dari Lapisan Logika Bisnis, caranya cukup sederhana:
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); } }
(Perhatikan bahwa dalam aplikasi nyata, kami mungkin akan memindahkan panggilan ke emailService
ke antrian pekerjaan agar tidak menambahkan latensi ke permintaan dan dengan harapan dapat melakukan percobaan ulang idempoten pada kegagalan (— bukan berarti pengiriman email sangat idempoten di tempat pertama). Selanjutnya, meneruskan seluruh objek pengguna ke layanan juga dipertanyakan. Masalah lain yang perlu diperhatikan adalah bahwa kita dapat menemukan diri kita dalam posisi di sini di mana server macet setelah pengguna bertahan tetapi sebelum email Ada pola mitigasi untuk mencegah hal ini, tetapi untuk tujuan pragmatisme, intervensi manusia dengan penebangan yang tepat mungkin akan bekerja dengan baik).
Dan begitulah — menggunakan Pola Repositori dengan kekuatan Generik, kami telah sepenuhnya memisahkan DAL kami dari BLL kami dan telah berhasil berinteraksi dengan repositori kami dengan cara yang aman untuk tipe. Kami juga telah mengembangkan cara untuk dengan cepat membangun repositori dalam memori yang sama-sama aman untuk tujuan pengujian unit dan integrasi, memungkinkan pengujian kotak hitam dan implementasi-agnostik yang sebenarnya. Semua ini tidak akan mungkin terjadi tanpa tipe Generik.
Sebagai penafian, saya ingin sekali lagi mencatat bahwa implementasi Repositori ini banyak kekurangan. Saya ingin membuat contoh sederhana karena fokusnya adalah pemanfaatan obat generik, itulah sebabnya saya tidak menangani duplikasi atau khawatir tentang transaksi. Implementasi repositori yang layak akan membutuhkan artikel sendiri untuk menjelaskan sepenuhnya dan benar, dan detail implementasi berubah tergantung pada apakah Anda melakukan Arsitektur N-Tier atau DDD. Itu berarti bahwa jika Anda ingin menggunakan Pola Repositori, Anda tidak boleh melihat implementasi saya di sini sebagai praktik terbaik.
Contoh Dunia Nyata — React State & Props
Status, ref, dan kait lainnya untuk Komponen Fungsional Bereaksi juga Generik. Jika saya memiliki antarmuka yang berisi properti untuk Task
s, dan saya ingin menyimpan koleksinya dalam Komponen Bereaksi, saya dapat melakukannya sebagai berikut:
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> ); };
Selain itu, jika kita ingin meneruskan serangkaian props ke dalam fungsi kita, kita dapat menggunakan tipe generik React.FC<T>
dan mendapatkan akses ke 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> ); };
Jenis props
disimpulkan secara otomatis sebagai IProps
oleh TS Compiler.
Kesimpulan
Dalam artikel ini, kita telah melihat banyak contoh Generik dan kasus penggunaannya yang berbeda, mulai dari kumpulan sederhana, hingga pendekatan penanganan kesalahan, hingga isolasi lapisan akses data, dan seterusnya. Dalam istilah yang paling sederhana, Generics mengizinkan kita untuk membangun struktur data tanpa perlu mengetahui waktu konkret di mana mereka akan beroperasi pada waktu kompilasi. Mudah-mudahan, ini membantu untuk membuka subjek sedikit lebih banyak, membuat gagasan Generik sedikit lebih intuitif, dan membawa kekuatan mereka yang sebenarnya.