Existential Type di Typescript
May 22, 2023 19:05 · 1543 words · 8 minute read

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 komputasi di item pertama bakal dipake sebagai argument di item yang kedua:
type Chunk<P> = [getProps: () => Promise<P>, comp: React.ComponentType<P>]
Lalu chunk-chunk ini bakal disimpan di dalam hashmap:
type ChunksMap = Map<string, Chunk<any>>
^^^
const chunks: ChunksMap = new Map()
chunks.set('header', [() => Promise.resolve({}), Header])
chunks.set('profile', [() => Api.getProfileProps(), Profile])
chunks.set('sidebar', [() => Api.getSidebarProps(), Sidebar])
Nah sekarang kebayang kan maksudnya, kamu cuman mau make type Chunk
di ChunksMap
tanpa harus mengisi parameter Chunk
. Kita gak bisa buat type parameter P
untuk ChunksMap
(type ChunksMap<P> = ...
) dan harus fallback ke any
karena masing-masing Chunk
dapat memiliki instance P
yang berbeda-beda; P
bisa berupa {}
, ProfileProps
, atau SidebarProps
.
Padahal tau sendiri kan any
gak boleh diandelin di sini karena, misal, Api.getSidebarProps()
jadi punya potensi untuk ngisi props-nya component 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.
type ChunksMap = Map<string, Chunk<exists P>>
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
Singkatnya function yang ditulis menggunakan gaya CPS menerima satu argument tambahan berupa function lain (continuation function) yang akan memproses hasil komputasi function yang pertama tadi. Ekspresi (1 + 2) * 3
bila ditulis menggunakan CPS akan menjadi
const add = (x, y) => (next) => next(x + y)
const mul = (x, y) => (next) => next(x * y)
const run = (next) => add(1,2)((r) => mul(r, 3)(next))
run(console.log)
Bila style ini diterapkan untuk meng-encode existential type:
const createChunk: CreateChunk = (chunk) => (next) => next(chunk)
type CreateChunk = <P>(_: Chunk<P>) => ChunkCPS
type ChunkCPS = <R>(next: <P>(_: Chunk<P>) => R) => R
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 parametertype Identity<T> = (val: T) => T
Namun ia bisa juga ditulis di sebalah kanan persamaan
type Identity = <T>(val: T) => T
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
.
type ChunksMap = Map<string, ChunkCPS> // No more type arguments!
const chunks: ChunksMap = new Map()
chunks.set('header', createChunk([() => Promise.resolve({}), Header]))
chunks.set('profile', createChunk([() => Api.getProfileProps(), Profile]))
chunks.set('sidebar', createChunk([() => Api.getSidebarProps(), Sidebar]))
Hasil “expansi kode” di atas beserta type instantiation-nya kira-kira berupa:
chunks.set('header', (next) => next<{}>([..., Header]))
chunks.set('profile', (next) => next<ProfileProps>([..., Profile]))
chunks.set('sidebar', (next) => next<SideBarProps>([..., Sidebar]))
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.
chunks.forEach((unwrap, key) => {
const el = document.getElementById(key)
if (!el) return
unwrap(function next(chunk) {
const [fetchProps, comp] = chunk
fetchProps().then((props) => {
ReactDOM.hydrateRoot(el, React.createElement(comp, props))
})
})
})
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.
interface Transaction {
exists Token
generateToken(): Token
checkBalance(token: Token): number
deposit(amount: number, token: Token): number
debit(amount: number, token: Token): number
}
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
interface Transaction<Token> {
generateToken(): Token
checkBalance(token: Token): number
deposit(amount: number, token: Token): number
debit(amount: number, token: Token): number
}
type TransactionCPS = <R>(next: <Token>(_: Transaction<Token>) => R) => R
Mari lihat contoh di bawah ini, BankSyariah
sebagai implementor Transaction
menginstansiasi type Token
dengan symbol
. Dan BankRut
lebih memilih UUID yang bertipe string
.
const BankSyariah = () => {
const balance = 67_000_000
const ops: Transaction<symbol> = {
generateToken: () => Symbol('a token'),
checkBalance: (token) => balance,
deposit: (amount, token) => balance + amount,
debit: (amount, token) => balance - amount,
}
const doTransaction: TransactionCPS = (fn) => fn(ops)
return { doTransaction }
}
const BankRut = () => {
const ops: Transaction<string> = {
generateToken: () => uuidv4(),
// ...
}
// ...
}
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.
const finalBalance: number = BankSyariah().doTransaction((ops) => {
const token = ops.generateToken()
let balance = ops.checkBalance(token)
balance = ops.deposit(150_000, token)
balance = ops.debit(100_000, token)
return balance
})
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
. Kenapa gak sekalian error aja saya ndak tahu 🤷.
// const retrievedToken: unknown
const retrievedToken = BankSyariah().doTransaction((ops) => {
const token = ops.generateToken()
return token
})
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.
-
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 ↩︎