Types sebagai Hansip: Validasikan Business Logic-mu saat Compile Time

Aug 30, 2019 11:18 ยท 1284 words ยท 7 minute read #purescript #types

Types sebagai Hansip: Validasikan Business Logic-mu saat Compile Time
Image by Ryan McGuire from Pixabay

Sekitar setahun yang lalu dari penulisan artikel ini, saya pernah menulis artikel tentang Nominal Typing (di Typescript) yang berguna untuk membedakan satu type dengan type lainnya walaupun struktur data keduanya identik. Dari sudut pandang lain, artikel tersebut juga meyebutkan bahwa Nominal Typing dapat digunakan untuk membatasi programmer dari kemungkinan melakukan kesalahan-kesalahan business domain sekaligus meningkatkan code expressiveness dengan memanfaatkan type system.

Artikel ini kurang lebih membahas konsep yang sama namun ditelaah menggunakan type system di Purescript.

Masalah dengan String dan Angka

Anggap ada requirement project yang melibatkan konsep mata uang. Katakanlah Rupiah dan Euro. Dan ada function addMoney yang melakukan penjumlahan dua buah nominal uang.

PureScript
addMoney :: Number -> Number -> Number
addMoney a b = a + b

Pemodelan fungsi seperti ini terlalu loose dan berbahaya karena tidak mampu mencegah programmer ketika melakukan penjumlahan dua buah mata uang yang berbeda. Kita gak mau kan secara naif menjumlahkan Rupiah dengan Euro karena Rp1 jelas gak sama dengan โ‚ฌ1. Hal ini dapat menyebabkan komputasi yang tidak akurat dan bisa fatal kalau teledor ๐Ÿฅถ

Salah satu cara mungkin bisa dengan menamakan kedua buah variable dengan penamaan yang jelas.

PureScript
oneRupiah = 1.0
oneEuro = 1.0

Ini pun masih error-prone dan tidak menyentuh akar masalah. Fungsi addMoney masih bisa dipanggil dan gak bakal ngasih warning apa-apa ke programmer. Dengan kata lain kita masih memberikan ruang bagi data yang tidak valid untuk diproses. Big NO.

Hal yang terlihat sepele ini mengingatkan saya akan insiden tahun 1998 dimana NASA (hello flat-earthers!) merugi besar ketika kehilangan Martian Rover dalam ekspedisinya ke Mars hanya karena perbedaan metrics unit dalam kalkulasinya. Duh pak ๐Ÿ˜“

Pola yang sama juga bisa terjadi pada string. String has too much power. Ia mampu merepresentasikan apa saja yang bisa dibayangkan: karakter, kata, kalimat, nama, email, alamat, number (!!!), function, you name it.

Eh, function? Iya, function.

JavaScript
// javascript
eval("function fnName() { return 'jihad' }")

fnName() === 'jihad'

๐Ÿ‘ป๐Ÿ‘ป๐Ÿ‘ป

Being powerful is great! Tapi perlu dicatat bahwa power yang sesungguhnya itu bukan yang digunakan tanpa batas, the real power comes when you can control it ๐Ÿ˜Ž

yeah right

Bukanlah orang kuat yang sebenarnya dengan (selalu mengalahkan lawannya dalam) perkelahian, tetapi tidak lain orang kuat yang sebenarnya adalah yang mampu mengendalikan dirinya ketika marah.

โ€” Muhammad SAW

Udah dong ceramahnya pak haji, balik ke programming.

Sama kayak programming, menurut saya sesuatu bisa dikatakan powerful ketika dapat membatasi programmer dari kemungkinan melakukan hal-hal gila. Dalam hal ini, batasan tersebut adalah type system.

Newtype

Di Purescript, newtype bersifat opaque yang berarti walaupun secara struktur dan runtime representation-nya persis sama, mereka dianggap berbeda saat compile time.

PureScript
-- fileA.purs
module FileA where

newtype Rupiah = Rupiah Number
derive newtype instance eqRupiah :: Eq Rupiah

-- fileB.purs
module FileB where

newtype Rupiah = Rupiah Number
derive newtype instance eqRupiah :: Eq Rupiah

-- main.purs
import FileA as FileA
import FileB as FileB

serebu = FileA.Rupiah 1000.0
seceng = FileB.Rupiah 1000.0

result = serebu != seceng -- error โŒ

-- | Could not match type
-- |
-- |   Rupiah
-- |
-- | with type
-- |
-- |   Rupiah

Newtype sangat berguna untuk membuat distinction dari satu tipe data yang sama. Seperti ketika ingin membedakan konsep name, address, url yang biasanya diekspresikan dengan string biasa.

PureScript
newtype Name = Name String
newtype Address = Address String
newtype URL = URL String

yell :: Name -> String
yell (Name x) = toUpper x

valid = yell (Name "jihad") == "JIHAD" -- typechecked โœ…

invalid  = yell (Address "Tangerang")   -- error โŒ
invalid' = yell (URL "https://url.com") -- error โŒ

Kembali ke masalah utama. Dengan teknik ini, kita bisa dengan mudah membedakan mata uang Rupiah dengan Euro!

PureScript
newtype Rupiah = Rupiah Number
newtype Euro = Euro Number

Cuman masalahnya, bagaimana type signature dari fungsi addMoney???

PureScript
addMoney :: Rupiah -> Rupiah -> Rupiah
addMoney :: Rupiah -> Euro -> Rupiah
...
addMoney :: Euro -> Euro -> Euro

Nope. Bukan ini yang kita mau. Kita ingin type signature fungsi addMoney polymorphic terhadap berbagai jenis mata uang.

Kind Signature

Untuk membuat fungsi addMoney bekerja dengan Rupiah dan Euro, mereka harus dikelompokkan ke dalam sesuatu yang sama. Mari kita sebut Currency.

PureScript
newtype Currency a = Amount Number

addMoney :: โˆ€ a. Currency a -> Currency a -> Currency a
addMoney (Amount x) (Amount y) = Amount (x + y)

Kita bisa lihat bahwa type variable a tidak muncul di sebalah kanan persamaan, yang membuat type Currency ini Phantom Type. Lalu gunanya a ini apa?

Type variable a berguna sebagai tag, sebagai pembeda antara satu mata uang dengan mata uang lainnya.

PureScript
tenEuro :: Currency "euro"
tenEuro = Amount 10.0

tenRupiah :: Currency "rupiah"
tenRupiah = Amount 10.0

Untuk artikel yang lebih lengkap mengenai kind di Purescript, bisa mampir ke Term, Type, dan Kind di Purescript.

Sekarang type variable a sudah diisi oleh type level string (Symbol): “euro” dan “rupiah”. Kita perlu modifikasi definisi Currency sedikit karena, seperti yang kita tahu, type variable di Purescript by default memiliki kind Type sedangkan type level string punya kind Symbol.

PureScript
newtype Currency (a :: Symbol) = Amount Number

Lalu batasi export data constructor Currency dan buat factory function sesuai kebutuhan project.

PureScript
module Project.Currency
  ( Currency -- data constructor tidak di-export
  , rupiah   -- factory function untuk Rupiah
  , euro     -- factory function untuk Euro
  , addMoney
  ) where

newtype Currency (a :: Symbol) = Amount Number

rupiah :: Number -> Currency "rupiah"
rupiah = Amount

euro :: Number -> Currency "euro"
euro = Amount

addMoney :: โˆ€ a. Currency a -> Currency a -> Currency a
addMoney (Amount x) (Amount y) = Amount (x + y)

-- Contoh penggunaan
tenEuro = (euro 6.0) `addMoney` (euro 4.0)
tenRupiah = (rupiah 5.0) `addMoney` (rupiah 5.0)

Dengan pattern seperti ini, data invalid yang dihasilkan dari penjumlahan nominal dua buah mata uang yang berbeda pun dapat dicegah:

PureScript
notValid = tenEuro `addMoney` tenRupiah
                              ^^^^^^^^^
-- |  Could not match type
-- |
-- |    "rupiah"
-- |
-- |  with type
-- |
-- |    "euro"

Custom Kind

Pemodelan di atas masih belum bisa terlepas dari masalah string. Sekalipun ada di type level, pemodelan dengan string tetap rawan akan typo dan compiler tidak bisa menangkap kesalahan ini.

PureScript
euroToRupiah :: Currency "euro" -> Currency "rupiahh_typo"
euroToRupiah (Amount eur) = Amount (eur * 15703.0)

Kita harus mempersempit scope karena lagi, string can contain anything: dengan cara membuat kind kita sendiri.

PureScript
-- Buat custom kind
foreign import kind CurrencyK
foreign import data Rupiah :: CurrencyK
foreign import data Euro :: CurrencyK

-- Ubah dari `Symbol` ke `CurrencyK`
newtype Currency (a :: CurrencyK) = Amount Number

-- Bye-bye typo!
euroToRupiah :: Currency Euro -> Currency Rupiah
euroToRupiah (Amount e) = Amount (e * 15703.0)

Now the code looks safe already! ๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰

Penutup

Ada beberapa hal penting yang bisa diambil di sini.

Yang pertama: having too much power is dangerous. Adanya type system yang berperan sebagai hansip selama ngoding dapat mencegah programmer dari kesalahan-kesalahan kecil nan fatal. Gak kebayang kalau masalah yang kelihatannya sepele ini bocor ke production dan merugikan banyak pihak.

Yang kedua, yang mungkin tidak terpikirkan sebelumnya: mengalihkan validation dari runtime ke compile time (dengan types) dapat menghemat banyak waktu debugging nantinya dan mengurangi penulisan test! Karena memang feedbacknya langsung terlihat: program compile atau tidak. Sedari awal kita tidak pernah membuat runtime validation secuilpun hanya untuk memastikan supaya Rupiah tidak tertukar dengan Euro. Namun code tetap safe dan hasil compile-nya pun gak kalah concise ๐Ÿ˜‰

currency in purescript generated currency code

Type system is there for a reason. Anggapan-anggapan bahwa type system membuat programmer tidak bebas memang ada benarnya, tapi perlu dibarengi dengan kesadaran bahwa menjadi bebas tidak serta merta terbebas dari apapun. Ada konsekuensinya. Ada harga yang harus dibayar. Dan semua keputusan dalam programming adalah trade-off.

Edit on