Existential Type di Typescript

  •  8 min read
Image by <a href="https://pixabay.com/users/ambermb-3121132/?utm_source=link-attribution&amp;utm_medium=referral&amp;utm_campaign=image&amp;utm_content=1635065">ambermb</a> from <a href="https://pixabay.com//?utm_source=link-attribution&amp;utm_medium=referral&amp;utm_campaign=image&amp;utm_content=1635065">Pixabay</a>
Image by ambermb from Pixabay

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:

type Chunk<P> = [getProps: () => Promise<P>, comp: React.ComponentType<P>]

Masalah baru muncul bila type ini disimpan di dalam suatu collection:

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])

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.

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

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

const add = (x, y) => (next) => next(x + y)
const mul = (x, y) => (next) => next(x * y)
const run = (next) => {
const n1 = add(1, 2)
const n2 = n1((prevResult) => mul(prevResult, 3))
n2(next)
}
run(console.log)

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:

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:

  1. Keduanya tak memiliki type parameter, karena…
  2. Deklarasi type P dan R berada di RHS.
RHS? LHS?

Umumnya generic type ditulis di LHS (sebelah kiri persamaan) sebagai type parameter

type 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.

4 collapsed lines
const createChunk: CreateChunk = (chunk) => (next) => next(chunk)
type CreateChunk = <P>(_: Chunk<P>) => ChunkCPS
type ChunkCPS = <R>(next: <P>(_: Chunk<P>) => R) => R
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:

  1. Propositions as types. Types dapat dilihat sebagai suatu statement yang, jika benar (‘true’), memiliki bukti yang direpresentasikan lewat runtime value. Misal number, bisa dibuktikan lewat 1, 2, 99, dst. Atau type string yang bisa dibuktikan dengan "any_string". Setiap type di Typescript punya representasi runtime value, kecuali type never. Ia tak memiliki runtime value. Karenanya, type never bersifat ‘false’.
  2. 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

  1. Not<A> == <A>(_: A) => never, dan
  2. Not<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:

  1. 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]
  2. 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 // imaginary syntax
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:

  1. Consumer Transaction gak tahu menahu soal type ini. “Pokoknya ada/eksis type yang digunakan oleh Transaction”. Gimana bentuk type-nya, wallahu a’lam.
  2. Implementor Transaction memiliki kemampuan untuk menentukan concrete type dari Token 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 (menurut saya lebih type-safe lagi kalau ini error, tapi TS is TS).

// 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:

  1. Bila consumer dapat menentukan instance type-nya, maka ia universal.
  2. 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

  1. Selengkapnya bisa dibaca di Type systems and logic

  2. Tak berlaku di classical logic dimana negasi itu reversible. Type system menggunakan constructive logic.

  3. https://stackoverflow.com/a/14299983. Penjelasan dengan gambar dapat ditemukan di sini