Existential Type di Typescript
Motivasi
Pernah gak sih kamu pengen make generic type tanpa harus menyuplai parameternya dengan type lain? Mungkin rada abstrak kali ya, tapi coba deh bayangin kamu punya tuple dimana hasil komputasi di item pertama bakal dipake sebagai argument di item yang kedua:
Masalah baru muncul bila type ini disimpan di dalam suatu collection:
Kita terpaksa menggunakan any
karena masing-masing item di Map
dapat memiliki props yang berbeda-beda; bisa berupa {}
, ProfileProps
, atau SidebarProps
. Membuat type parameter P
untuk ChunksMap
(type ChunksMap<P> = ...
) juga tak mungkin karena malah membuat semua item di Map
memiliki type props yang sama.
Padahal kita tau any
gak boleh diandelin di sini karena gak type-safe. Misal, Api.getSidebarProps()
yang seharusnya diperuntukkan untuk component Sidebar
malah jadi punya potensi untuk mengisi props-nya Profile
. Big no.
Andai saja Typescript menyediakan suatu mekanisme yang memungkinkan untuk bilang, “yo type checker, ini ada suatu type yang dibutuhkan Chunk
, tapi gw gak tau detail type-nya. Yang gw tau dia ada dan dipake”, mungkin kodenya akan tampak lebih ekspresif.
Inilah yang dimaksud dengan existential type. Walau Typescript belum mendukung fitur ini, bukan berarti gak ada cara lain untuk ngakalinnya! Existential type bisa di-encode dengan CPS (continuation-passing style).
Solusi
Mari bahas terlebih dahulu apa itu CPS dengan singkat. Function yang ditulis menggunakan gaya CPS menerima satu argument tambahan berupa function lain (continuation function) yang akan memproses hasil komputasi function sebelumnya. Ekspresi (1 + 2) * 3
bila ditulis menggunakan CPS akan menjadi
next
adalah function yang akan melanjutkan hasil komputasi. Satu expression ke expression lainnya disambung dengan function.
Bila style ini diterapkan untuk meng-encode existential type:
Ada hal yang menarik dengan type CreateChunk
dan ChunkCPS
:
- Keduanya tak memiliki type parameter, karena…
- Deklarasi type
P
danR
berada di RHS.
RHS? LHS?
Umumnya generic type ditulis di LHS (sebelah kiri persamaan) sebagai type parameter
Namun ia bisa juga ditulis di sebalah kanan persamaan
Di Typescript, kemampuan mendeklarasikan generic type di RHS hanya bisa dilakukan oleh function. Di bahasa lain seperti Haskell, cukup dengan keyword forall
.
Hal menarik selanjutnya yaitu deklarasi type P
di scope yang berbeda dengan type variable R
, ia muncul satu level di bawahnya. Teknik ini biasa disebut Rank-N types.
Rasa-rasanya mirip film Inception tapi pake types. P
ada di bawah R
, dan R
tidak keluar dari ChunkCPS
, membuatnya parameterless type. Tanpa parameter, kita tak lagi punya kewajiban untuk mengisinya dengan type argument.
Mari perbarui type ChunksMap
.
Hasil “expansi kode” di atas beserta type instantiation-nya kira-kira berupa:
Terus gimana caranya biar value di dalam ChunksMap
bisa dieksekusi? Kita tahu bahwa value-value ini hanyalah berupa function (sebut saja unwrap
) yang menerima function lain (next
) yang akhirnya mengkonsumsi Chunk<P>
. Sekarang tinggal ikuti type-nya.
Meng-hover kursor di atas fetchProps
dan comp
menghasilkan () => Promise<P>
dan React.ComponentType<P>
. Kita gak kehilangan type P
! 🎉
Kok Bisa CPS?
Nah ini pertanyaan bagus. Gimana ceritanya existential type bisa diekspresikan lewat CPS? Saya coba jawab dengan pengetahuan logic saya yang terbatas. Mari pahami 2 hal terlebih dahulu:
- Propositions as types. Types dapat dilihat sebagai suatu statement yang, jika benar (‘true’), memiliki bukti yang direpresentasikan lewat runtime value. Misal
number
, bisa dibuktikan lewat1
,2
,99
, dst. Atau typestring
yang bisa dibuktikan dengan"any_string"
. Setiap type di Typescript punya representasi runtime value, kecuali typenever
. Ia tak memiliki runtime value. Karenanya, typenever
bersifat ‘false’. - Menurut ilmu logic, suatu value bisa diungkapkan lewat double negation.
type A = Not<Not<A>>
.true
sama dengan!!true
Mari kita ambil contoh type string
. Ia bersifat ‘true’ (ada representasi value saat runtime). Not<string>
seharusnya bersifat ‘false’, layaknya never
. Not<string>
juga dapat dieskpresikan lewat (str: string) => never
yang kira-kira dibaca, “kalau saya punya sebuah string, saya akan membuat sesuatu yang mustahil ada!“. Ini sama aja kayak bilang, dengan string kita bisa menghasilkan “bukti” untuk type never
. Ini kontradiksi, gak boleh terjadi. Oleh sebab itu (str: string) => never
praktisnya bersifat ‘false’.1
Lewat asas ini bisa kita tarik rumus dimana 2
Not<A> == <A>(_: A) => never
, danNot<Not<A>> == (fn: <A>(_: A) => never) => never
Balik ke type Chunk
di bagian sebelumnya, double negation dari Chunk
adalah (next: <P>(chunk: Chunk<P>) => never) => never
. Satu masalah besar dengan type ini adalah ia tak berguna: kita cuman dapat never
, sedangkan kita perlu sesuatu yang konkrit agar komputasi ini bermakna. Lihatlah Array<T>
yang bisa dicari tahu panjang array-nya, diambil element pertamanya, dll, namun type T
tetap tidak bisa kita konsumsi secara langsung karena ia abstrak. Dalam hal ini Array
-lah yang membuat komputasi dengan T
berguna. Lewat analogi yang sama kita musti substitusi never
dengan suatu quantifier (type) agar dapat menghasilkan value yang bisa dikonsumsi, menjadi <R>(next: <P>(chunk: Chunk<P>) => R) => R
.3 Terlihat familiar?
Kita juga bisa mengaplikasikan double negation ke union type lho!
Untuk union A | B
:
Not<A | B>
menghasilkan(<A>(a: A) => R) & (<B>(b: B) => R)
. Bila dieskpresikan dengan tuple menjadi[<A>(a: A) => R, <B>(b: B) => R]
Not<Not<A | B>>
menghasilkan<R>(fnA: <A>(a: A) => R, fnB: <B>(b: B) => R) => R
Private Type
Sekarang saatnya kita eksplor studi kasus lain dimana kita ingin menyembunyikan suatu type dari dunia luar dengan memanfaatkan existential type.
Di sini type Token
hanya digunakan di dalam Transaction
, gak bocor keluar. Dua hal yang perlu dicatat:
- Consumer
Transaction
gak tahu menahu soal type ini. “Pokoknya ada/eksis type yang digunakan olehTransaction
”. Gimana bentuk type-nya, wallahu a’lam. - Implementor
Transaction
memiliki kemampuan untuk menentukan concrete type dariToken
dan punya akses penuh terhadapnya.
Menggunakan CPS, type Transaction
berubah menjadi
Mari lihat contoh di bawah ini, BankSyariah
sebagai implementor Transaction
menginstansiasi type Token
dengan symbol
. Dan BankRut
lebih memilih UUID yang bertipe string
.
Berbanding terbalik dengan implementor, pengguna Transaction
tidak bisa menginspeksi type Token
. Ia terlihat seperti generic type biasa—tak diketahui instance-nya. Dan sebenarnya gak penting juga untuk diketahui.
Menempatkan kursor di atas token
pada baris kedua memberikan informasi inference const token: Token
.
Type Token
gak bisa kabur keluar dari scope-nya, artinya mengembalikan type Token
di return statement bakal resolve ke unknown
(menurut saya lebih type-safe lagi kalau ini error, tapi TS is TS).
Sebenernya sih ya bisa aja cheating pake semacam console.log
gitu kan untuk tahu bentuk aslinya. Tapi kalau kamu lagi buat sebuah kontrak dan ada type yang ingin disembunyikan—baik untuk mengurangi type parameter di sisi consumer maupun untuk alasan correctness semata—existential type mungkin bisa jadi awal yang baik.
Penutup
Perbedaan mendasar antara universally quantified variable (type parameter biasa) dengan existentially quantified variable (well, existetial type) adalah:
- Bila consumer dapat menentukan instance type-nya, maka ia universal.
- Bila consumer harus menggunakan type yang sudah ditentukan untuknya, maka ia eksistensial.
Dan dari contoh-contoh di atas, baik P
-nya Chunk
maupun Token
-nya Transaction
consumer tak punya kontrol untuk menginstansiasinya. Karena itu keduanya eksistensial.
Sayangnya bahasa sepopuler Typescript belum sepenuhnya mendukung fitur ini, padahal potensinya besar. Problem yang kelihatannya sederhana jadi butuh solusi yang kompleks: harus ditulis menggunakan continuation-passing style. Semoga kedepannya Typescript segera mengadopsi existential type.
Footnotes
-
Selengkapnya bisa dibaca di Type systems and logic ↩
-
Tak berlaku di classical logic dimana negasi itu reversible. Type system menggunakan constructive logic. ↩
-
https://stackoverflow.com/a/14299983. Penjelasan dengan gambar dapat ditemukan di sini ↩