TypeScript Tingkat Lanjut dengan Contoh Nyata (Bagian 2)
Diterbitkan: 2020-11-20Saatnya untuk melanjutkan (dan semoga selesai) tutorial TypeScript kami. Jika Anda melewatkan posting sebelumnya yang telah kami tulis tentang TypeScript, ini dia: pengantar awal kami untuk TypeScript, dan bagian pertama dari tutorial ini di mana saya menjelaskan contoh JavaScript yang sedang kami kerjakan dan langkah-langkah yang kami ambil untuk memperbaikinya sebagian .
Hari ini kita akan menyelesaikan contoh kita dengan melengkapi semua yang masih kurang. Secara khusus, pertama-tama kita akan melihat cara membuat tipe yang merupakan versi parsial dari tipe lain yang ada. Kami kemudian akan melihat cara mengetik dengan benar tindakan toko Redux menggunakan serikat tipe, dan kami akan membahas keuntungan yang ditawarkan serikat tipe. Dan, akhirnya, saya akan menunjukkan cara membuat fungsi polimorfik yang tipe pengembaliannya bergantung pada argumennya.
Tinjauan Singkat Tentang Apa yang Telah Kami Lakukan Sejauh Ini…
Di bagian pertama tutorial kami menggunakan (bagian dari) toko Redux yang kami ambil dari Nelio Content sebagai contoh kerja kami. Semuanya dimulai sebagai kode JavaScript biasa yang harus ditingkatkan dengan menambahkan tipe konkret yang membuatnya lebih kuat dan dapat dipahami. Jadi, misalnya, kami mendefinisikan tipe berikut:
type PostId = number; type Day = string; type Post = { id: PostId; title: string; author: string; day: Day; status: string; isSticky: boolean; }; type State = { posts: Dictionary<PostId, Post>; days: Dictionary<Day, PostId[]>; }; yang membantu kami memahami, sekilas, jenis informasi yang digunakan toko kami. Dalam contoh khusus ini, misalnya, kita dapat melihat bahwa status aplikasi kita menyimpan dua hal: daftar posts (yang telah kita indeks melalui PostId ) dan struktur yang disebut days yang, pada hari tertentu, mengembalikan daftar pengenal pos. Kita juga dapat melihat atribut (dan tipe spesifiknya) yang akan kita temukan di objek Post .
Setelah tipe ini didefinisikan, kami mengedit semua fungsi dari contoh kami untuk menggunakannya. Tugas sederhana ini mengubah tanda tangan fungsi buram JavaScript:
// Selectors function getPost( state, id ) { ... } function getPostsInDay( state, day ) { ... } // Actions function receiveNewPost( post ) { ... } function updatePost( postId, attributes ) { ... } // Reducer function reducer( state, action ) { ... }untuk tanda tangan fungsi TypeScript yang cukup jelas:
// Selectors function getPost( state: State, id: PostId ): Post | undefined { ... } function getPostsInDay( state: State, day: Day ): PostId[] { ... } // Actions function receiveNewPost( post: Post ): any { ... } function updatePost( postId: PostId, attributes: any ): any { ... } // Reducer function reducer( state: State, action: any ): State { ... } Fungsi getPostsInDay adalah contoh yang sangat baik tentang seberapa banyak TypeScript akan meningkatkan kualitas kode Anda. Jika Anda melihat pada rekan JavaScript, Anda benar-benar tidak tahu fungsi apa yang akan dikembalikan. Tentu, namanya mungkin mengisyaratkan jenis hasil (apakah ini daftar posting, mungkin?), Tetapi Anda harus melihat kode sumber fungsi (dan mungkin juga tindakan dan reduksi) untuk memastikan (ini sebenarnya daftar ID posting). Seseorang dapat memperbaiki situasi ini dengan menamai hal-hal yang lebih baik ( getIdsOfPostsInDay , misalnya), tetapi tidak ada yang seperti tipe konkret untuk menghilangkan keraguan: PostId[] .
Jadi, sekarang Anda siap dengan keadaan saat ini, saatnya untuk melanjutkan dan memperbaiki semua yang kita lewati minggu lalu. Secara khusus, kita tahu bahwa kita perlu mengetikkan atribut attributes dari fungsi updatePost dan kita perlu menentukan jenis tindakan yang akan kita miliki (perhatikan bahwa di reducer , atribut action sekarang adalah tipe any ).
Cara Mengetik Objek yang Atributnya adalah Subset dari Objek Lain
Mari kita pemanasan dengan memulai dengan sesuatu yang sederhana. Fungsi updatePost menghasilkan tindakan yang menandakan niat kita untuk memperbarui atribut tertentu dari ID postingan yang diberikan. Berikut tampilannya:
function updatePost( postId: PostId, attributes: any ): any { return { type: 'UPDATE_POST', postId, attributes, }; }dan berikut adalah cara action yang digunakan oleh reducer untuk mengupdate postingan di toko kita :
function reducer( state: State, action: any ): State { // ... switch ( action.type ) { // ... case 'UPDATE_POST': if ( ! state.posts[ action.postId ] ) { return state; } const post = { ...state.posts[ action.postId ], ...action.attributes, }; return { ... }; } // ... }Seperti yang Anda lihat, peredam mencari pos di toko dan, jika ada, memperbarui atributnya dengan menimpanya menggunakan yang disertakan dalam tindakan.
Tapi apa sebenarnya attributes dari sebuah tindakan? Yah, mereka jelas sesuatu yang terlihat mirip dengan Post , karena mereka seharusnya menimpa atribut yang mungkin kita temukan di sebuah postingan:
type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Post; };tetapi jika kita mencoba menggunakan ini, kita akan melihat bahwa itu tidak berhasil:
const post: Post = { id: 1, title: 'Title', author: 'Ruth', day: '2020-10-01', status: 'draft', isSticky: false, }; const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; karena kita tidak ingin attributes menjadi Post itu sendiri; kita ingin itu menjadi subset dari atribut Post (yaitu kita ingin menentukan hanya atribut dari objek Post yang akan kita timpa).
Untuk mengatasi masalah ini, cukup gunakan jenis utilitas Partial :
type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Partial<Post>; };Dan itu saja! Atau itu?
Memfilter Atribut Secara Eksplisit
Cuplikan sebelumnya masih salah, karena mungkin saja mendapatkan beberapa kesalahan runtime yang tidak diperiksa oleh kompiler TypeScript. Inilah alasannya: tindakan yang menandakan pembaruan kiriman memiliki dua argumen, ID kiriman dan kumpulan atribut yang ingin kita perbarui. Setelah kami menyiapkan tindakan, peredam bertanggung jawab untuk menimpa pos yang ada dengan nilai baru:
const post = { ...state.posts[ action.postId ], ...action.attributes, }; Dan justru itulah bagian yang salah dalam kode kita; mungkin saja atribut postId tindakan memiliki ID pot x dan atribut id dalam attributes memiliki ID posting yang berbeda y :
const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 2, author: 'Toni', }, }; Ini jelas merupakan tindakan yang valid, sehingga TypeScript tidak memicu kesalahan apa pun, tetapi kami tahu seharusnya tidak demikian. Atribut id dalam attributes (jika ada) dan atribut postId harus memiliki nilai yang sama, atau kita memiliki tindakan yang tidak koheren. Jenis tindakan kami tidak tepat karena memungkinkan kami menentukan situasi yang seharusnya tidak mungkin… jadi bagaimana kami bisa memperbaikinya? Cukup mudah: ubah saja tipe ini sehingga skenario yang seharusnya tidak mungkin menjadi benar-benar tidak mungkin.
Solusi pertama yang saya pikirkan adalah sebagai berikut: hapus atribut postId dari tindakan dan tambahkan ID di attributes atribut:
type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post>; }; function updatePost( postId: PostId, attributes: Partial<Post> ): UpdatePostAction { return { type: 'UPDATE_POST', attributes: { ...attributes, id: postId, }, }; } Kemudian, perbarui peredam Anda sehingga menggunakan action.attributes.id alih-alih action.postId untuk menemukan dan menimpa postingan yang ada.
Sayangnya, solusi ini tidak ideal, karena attributes adalah "posting parsial", ingat? Artinya, secara teori, atribut id mungkin ada atau tidak ada di objek attributes . Tentu, kami tahu itu akan ada di sana, karena kamilah yang menghasilkan tindakan… tetapi tipe kami masih tidak tepat. Jika di masa mendatang seseorang memodifikasi fungsi updatePost dan tidak memastikan bahwa attributes menyertakan postId , tindakan yang dihasilkan akan valid menurut TypeScript tetapi kode kita tidak akan berfungsi:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };Jadi, jika kita ingin TypeScript melindungi kita, kita harus setepat mungkin saat menentukan tipe dan memastikan mereka membuat status yang tidak mungkin menjadi tidak mungkin. Mempertimbangkan semua ini, kami hanya memiliki dua opsi yang tersedia:
- Jika kita memiliki atribut
postIddalam tindakan (seperti yang kita lakukan di awal), maka objekattributestidak boleh berisi atributid. - Sebaliknya, jika tindakan tidak memiliki atribut
postId, makaattributesharus berisi atributid.
Solusi pertama dapat dengan mudah ditentukan menggunakan tipe utilitas lain, Omit , yang memungkinkan kita membuat tipe baru dengan menghapus atribut dari tipe yang ada:
type UpdatePostAction = { type: 'UPDATE_POST'; postId: PostId, attributes: Partial< Omit<Post, 'id'> >; };yang berfungsi seperti yang diharapkan:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 1, author: 'Toni', }, }; Untuk opsi kedua, kita harus secara eksplisit menambahkan atribut id di atas tipe Partial<Post> yang kita definisikan:
type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post> & { id: PostId }; };yang, sekali lagi, memberi kita hasil yang diharapkan:

const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };Jenis Serikat
Di bagian sebelumnya, kita telah melihat cara mengetik salah satu dari dua tindakan yang dimiliki toko kita. Mari kita lakukan hal yang sama dengan tindakan kedua. Mengetahui bahwa receiveNewPost terlihat seperti ini:
function receiveNewPost( post: Post ): any { return { type: 'RECEIVE_NEW_POST', post, }; }jenisnya dapat didefinisikan sebagai berikut:
type ReceiveNewPostAction = { type: 'RECEIVE_NEW_POST'; post: Post; };Mudah, bukan?
Sekarang mari kita lihat peredam kita: dibutuhkan state dan action (yang tipenya belum kita ketahui) dan menghasilkan State baru :
function reducer( state: State, action: any ): State { ... } Toko kami memiliki dua jenis tindakan yang berbeda: UpdatePostAction dan ReceiveNewPostAction . Jadi apa jenis argumen action ? Satu atau yang lain, kan? Ketika sebuah variabel dapat menerima lebih dari satu tipe A , B , C , dan seterusnya, tipenya adalah gabungan tipe. Artinya, tipenya bisa A atau B atau C , dan seterusnya. Tipe gabungan adalah tipe yang nilainya dapat berupa salah satu tipe yang ditentukan dalam serikat itu.
Inilah cara jenis Action kami dapat didefinisikan sebagai jenis gabungan:
type Action = UpdatePostAction | ReceiveNewPostAction; Cuplikan sebelumnya hanya menyatakan bahwa suatu Action dapat berupa turunan dari tipe UpdatePostAction atau turunan dari tipe ReceiveNewPostAction .
Jika sekarang kita menggunakan Action di peredam kita:
function reducer( state: State, action: Action ): State { ... }kita dapat melihat bagaimana versi baru dari kode berbasis kita ini, yang diketik dengan baik, bekerja dengan lancar.
Bagaimana Jenis Serikat Menghilangkan Kasus Default
“Tunggu sebentar,” Anda mungkin berkata, “tautan sebelumnya tidak berfungsi dengan baik, kompilator memicu kesalahan!” Memang, menurut TypeScript, peredam kami berisi kode yang tidak dapat dijangkau:
function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... } return state; //Error! Unreachable code }Tunggu apa? Biarkan saya menjelaskan apa yang terjadi di sini ...
Jenis serikat Action yang kami buat sebenarnya adalah jenis serikat yang didiskriminasi. Jenis serikat yang didiskriminasi adalah jenis serikat di mana semua jenisnya berbagi atribut umum yang nilainya dapat digunakan untuk membedakan satu jenis dari yang lain.
Dalam kasus kami, dua jenis Action memiliki atribut type yang nilainya RECEIVE_NEW_POST untuk ReceiveNewPostAction dan UPDATE_POST untuk UpdatePostAction . Karena kita tahu bahwa suatu Action , tentu saja, merupakan turunan dari satu tindakan atau yang lain, dua cabang switch kami mencakup semua kemungkinan: baik action.type adalah RECEIVE_NEW_POST atau UPDATE_POST . Oleh karena itu, return akhir menjadi mubazir dan tidak dapat dijangkau.
Misalkan, kemudian, kita menghapus return itu untuk memperbaiki kesalahan ini. Apakah kami mendapatkan sesuatu, selain menghapus kode yang tidak perlu? Jawabannya iya. Jika sekarang kita menambahkan jenis tindakan baru dalam kode kita:
type Action = | UpdatePostAction | ReceiveNewPostAction | NewFeatureAction; type NewFeatureAction = { type: 'NEW_FEATURE'; // ... }; tiba-tiba pernyataan switch di peredam kami tidak lagi mencakup semua skenario yang mungkin:
function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... // case NEW_FEATURE is missing... } // return undefined is now implicit } Ini berarti peredam mungkin secara implisit mengembalikan nilai yang undefined jika kita memanggilnya menggunakan tindakan bertipe NEW_FEATURE , dan itu adalah sesuatu yang tidak cocok dengan tanda tangan fungsi. Karena ketidakcocokan ini, TypeScript mengeluh dan memberi tahu kami bahwa kami kehilangan cabang baru untuk menangani jenis tindakan baru ini.
Fungsi Polimorfik dengan Tipe Pengembalian Variabel
Jika Anda telah sampai sejauh ini, selamat: Anda telah mempelajari semua yang perlu Anda lakukan untuk meningkatkan kode sumber aplikasi JavaScript Anda menggunakan TypeScript. Dan, sebagai hadiah, saya akan berbagi dengan Anda sebuah "masalah" yang saya temui beberapa hari yang lalu dan solusinya. Mengapa? Karena TypeScript adalah dunia yang kompleks dan menarik dan saya ingin menunjukkan kepada Anda sampai sejauh mana hal ini benar.
Di awal seluruh petualangan ini, kami telah melihat salah satu penyeleksi yang kami miliki adalah getPostsInDay dan bagaimana tipe pengembaliannya adalah daftar ID kiriman:
function getPostsInDay( state: State, day: Day ): PostId[] { return state.days[ day ] ?? []; }meskipun namanya mungkin mengembalikan daftar posting. Mengapa saya menggunakan nama yang menyesatkan, Anda bertanya-tanya? Nah, bayangkan skenario berikut: misalkan Anda ingin fungsi ini dapat mengembalikan daftar ID posting atau mengembalikan daftar posting yang sebenarnya, tergantung pada nilai salah satu argumennya. Sesuatu seperti ini:
const ids: PostId[] = getPostsInDay( '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( '2020-10-01', 'all' );Bisakah kita melakukannya di TypeScript? Tentu saja! Mengapa lagi saya akan membawa ini sebaliknya? Yang harus kita lakukan adalah mendefinisikan fungsi polimorfik yang hasilnya tergantung pada parameter input.
Jadi, idenya adalah kita menginginkan dua versi berbeda dari fungsi yang sama. Seseorang harus mengembalikan daftar PostId s jika salah satu atributnya adalah string id . Yang lain harus mengembalikan daftar Post s jika atribut yang sama adalah string all .
Mari kita buat keduanya:
function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[] { // ... } function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[] { // ... }Mudah, bukan? SALAH! Ini tidak bekerja. Menurut TypeScript, kami memiliki "implementasi fungsi duplikat."
Oke, mari kita coba sesuatu yang berbeda, kalau begitu. Mari gabungkan dua definisi sebelumnya menjadi satu fungsi:
function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' = 'id' ): PostId[] | Post[] { if ( 'id' === mode ) { return state.days[ day ] ?? []; } return []; }Apakah ini berperilaku seperti yang kita inginkan? Aku takut itu tidak…
Inilah yang dikatakan oleh tanda tangan fungsi ini kepada kita: “ getPostsInDay adalah fungsi yang mengambil dua argumen, state dan mode yang nilainya dapat berupa id atau semua ; tipe pengembaliannya akan berupa daftar PostId s atau daftar Post s.” Dengan kata lain, definisi fungsi sebelumnya tidak menentukan di mana pun bahwa ada hubungan antara nilai yang diberikan ke argumen mode dan tipe pengembalian fungsi. Dan jadi kode seperti ini:
const state: State = { posts: {}, days: {} }; const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );valid dan tidak berperilaku seperti yang kita inginkan.
Oke, upaya terakhir. Bagaimana jika kita mencampur intuisi awal kita, di mana kita menggambarkan tanda fungsi konkret, dengan implementasi tunggal yang valid?
function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[]; function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[]; function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' ): PostId[] | Post[] { const postIds = state.days[ day ] ?? []; if ( 'id' === mode ) { return postIds; } return postIds .map( ( pid ) => getPost( state, pid ) ) .filter( ( p ): p is Post => !! p ); } Cuplikan sebelumnya memiliki implementasi fungsi yang valid yang berfungsi, tetapi mendefinisikan dua tanda tangan fungsi tambahan yang mengikat nilai konkret dalam mode dengan tipe pengembalian fungsi.
Menggunakan pendekatan ini, kode ini valid:
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );dan yang ini tidak:
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'all' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'id' );Kesimpulan
Dalam rangkaian posting ini kita telah melihat apa itu TypeScript dan bagaimana kita dapat menerapkannya dalam proyek kita. Jenis membantu kami mendokumentasikan kode dengan lebih baik dengan menyediakan konteks semantik. Selain itu, tipe juga menambahkan lapisan keamanan ekstra, karena kompiler TypeScript menangani validasi bahwa kode kita cocok satu sama lain dengan benar, seperti yang dilakukan Lego.
Pada titik ini Anda sudah memiliki semua alat yang diperlukan untuk meningkatkan kualitas pekerjaan Anda ke tingkat berikutnya. Semoga berhasil dalam petualangan baru ini!
Gambar unggulan oleh Mike Kenneally di Unsplash.
