Minggu Ini Ngapain

Ngoding Itu Cuman State dan Function


Di tweet abang Didiet ini, dia ada di thread menjelaskan soal apa itu state dan command. Dia nyontohin kode haskell dengan Command monad.

Banyak yg reply kalo ga tau itu apa. Kebetulan, state dan function itu mental model gw saat ngoding. Jadinya gw mikir kenapa ga sekalian gw jelasin aja kerangka berfikir gw saat ngoding.

Apa itu State dan Function?

Kita bisa fikirkan state sebagai kondisi program kita sekarang.

const umur: number = 1

Nah, umur ini tipe nya number. Kita bisa ubah number ini yang misalnya tadi 0 jadi input user.

let umur: number = 0
umur = req.body.umur

Number ini sayangnya bisa jadi -1. Bisa jadi NaN, bisa 0,5, bisa jadi string juga karena kita di typescript engga dicek tipenya. Salah satu hal yang bisa kita lakukan untuk melakukan validasi agar bisa yakin kalau tipe nya beneran number.

import { z } from "zod";

const req = { body: { umur: 10 } } as unknown // anggep ini dapet dari request handler

const schema = z.object({
  umur: z.number()
})

const umur: number = schema.parse(req.body).umur

Sekarang bisa dipastiin kalau umur itu number. Function .parse() dalam zod itu akan memastikan kalau tipenya number. Sekarang misalnya kita punya function printUmur(umur: number). Function ini definisinya

function printUmur(umur: number) {
  if (umur >= 0 && umur <= 12) {
    console.log("anak-anak")
  }  else if (umur >= 13 && umur <= 17) {
    console.log("remaja")
  } else {
    console.log("dewasa")
  }
}

Dalam kode ini, bisa tidak valid kalo umur negatif. Ya cukup masuk akal kalau memirkan kalau umur tidak bisa negatif kan ya. Tapi secara kode, tidak ada yang membatasi itu. Kita bisa aja sekarang menambahin divalidasi request kita.

📒NOTE: Memang bisa di buat fungsinya lebih baik urutannya agar logic error ini tidak ada. Tapi yaaa asumsi aja ada fungsi lain yang lebih kompleks gitu.

import { z } from "zod";

const req = { body: { umur: 10 } } as unknown // anggep ini dapet dari request handler

const schema = z.object({
  umur: z.number().min(0)
})

const umur: number = schema.parse(req.body).umur

printUmur(umur) // anak-anak

Sekarang umur negatif engga akan pernah sampai ke printUmur. Coba perhatiin apa yang barusan kita lakuin. Kita engga nambahin logic apa-apa ke program. Yang kita lakuin cuma ngilangin kemungkinan state yang bisa terjadi.

Dari sini gw bisa kasih definisi mental model nya. State itu data program di satu titik waktu. Function itu transisi dari satu state ke state lain. Dan hampir semua bug itu sebenernya cuma satu hal: state yang harusnya engga mungkin terjadi, ternyata bisa terjadi (well... sama kalo fungsi nya ngaco logicnya).

Desain Dipandu Tipe (Type Driven Design)

anjay berasa kayak dosen bahasanya

Kalau bug itu state yang engga valid, berarti cara paling gampang ngurangin bug ya jangan kasih kesempatan state engga valid itu ada. Disinilah tipe berperan. Tipe yang gw maksud itu compile time static definition yang ngasih tau data bisa di represenatsi dengan value apa aja. Bisa dibilang, tipe itu mendefinisikan ruang state yang mungkin.

number itu ruang state nya gede banget. Isinya ada -1, ada NaN, ada 0.5, ada Infinity dan semua hal aneh yang js punya. Padahal umur yang valid itu cuma sebagian kecil dari situ. Semakin lebar gap antara "yang bisa direpresentasikan tipe" dan "yang valid untuk program", semakin banyak tempat bug bisa muncul.

Contoh klasik yang sering banget kejadian di frontend:

let isLoading: boolean
let isError: boolean
let data: User | undefined

Tiga variable ini kombinasi state nya ada 8 (2^3 karena setiap state bisa di represenatsi dengan 2 value). Tapi yang valid cuma 3: lagi loading, error, atau sukses dapet data. Sisanya state aneh kayak isLoading: true dan isError: true barengan. State itu hal yg bisa muncul yg sebenernya kita ga mau, tapi tipe nya membolehkan. Dan yang tipe nya bolehin, suatu saat pasti kejadian di production. Disini banyak orang tergocek, ngerasa udah handle semua kasus padahal masih ada state aneh yang bisa nyelip.

Bandingin kalau kita desain tipe nya dulu:

type Status =
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "success"; data: User }

Sekarang state yang invalid itu tadi engga bisa direpresentasikan sama sekali. Compiler yang jagain programnya, bukan tergantung kedisiplin kita. Ada slogan terkenal untuk ini: make illegal states unrepresentable.

Type driven design itu ya ini. Sebelum nulis logic, desain dulu tipe nya biar ruang state nya sekecil mungkin. Logic nya nanti ngikutin sendiri, dan biasanya jadi lebih simpel karena banyak kasus yang engga perlu di handle lagi.

Parse Don't Validate

Balik ke contoh umur tadi. Kita udah validasi pake zod, tapi ada yang masih kurang. Setelah schema.parse(), tipe nya umur tetep aja number. Informasi "umur ini udah dicek engga negatif" itu ilang. Compiler engga tau. Jadi kalau ada function lain yang butuh umur valid, dia engga bisa bedain mana number yang udah dicek mana yang belum.

if (umur >= 0) {
  // di dalam sini kita TAU umur >= 0
  // tapi tipe nya masih number, pengetahuan itu ga kebawa
}
doSomething(umur) // disini udah ga ada jaminan apa-apa

Ini yang namanya validate: ngecek, terus buang hasil pengecekannya.

Parse itu beda. Parse mengubah data jadi tipe data yang beda, dan pengetahuannya kebawa di tipe nya.

type Umur = number & { readonly __brand: "Umur" }

function parseUmur(n: number): Umur {
  if (n < 0 || !Number.isInteger(n)) {
    throw new Error("umur tidak valid")
  }
  return n as Umur
}

function printUmur(umur: Umur) {
  // disini GA PERLU ngecek lagi
  // satu-satunya cara dapet Umur itu lewat parseUmur (idealnya gitu)
}

Sekarang printUmur(-1) itu compile error. Bukan runtime error, bukan bug di production jam 2 pagi (pagerduty alert trauma), tapi compile error. Pengecekan cuma terjadi sekali di parseUmur, dan setelah itu seluruh program bisa percaya sama tipe nya.

Di zod pun bisa dengan .brand():

const schema = z.object({
  umur: z.number().int().min(0).brand<"Umur">()
})

Konsep ini dari artikel Parse, Don't Validate nya Alexis King. Ini wajib baca menurut gw, salah satu artikel yang paling ngubah cara gw ngoding.

Dunia Luar, Dunia Dalam

Nah terus dimana kita harus nge-parse? Disini gw bagi program jadi dua dunia.

Dunia luar itu semua yang dateng dari luar program kita: HTTP request, response dari API orang, hasil query database, env variable, input user, isi file. Semua ini pada dasarnya unknown. Mau tipe nya ditulis apapun, runtime engga peduli. API orang bisa berubah, kolom database bisa null, user bisa ngetik apa aja.

Dunia dalam itu kode kita sendiri, tempat tipe-tipe kita beneran dijamin bener.

Aturannya satu: parse di perbatasan. Setiap data nyebrang dari dunia luar ke dunia dalam, dia harus lewat parser dulu. Sekali udah di dalem, engga perlu ngecek-ngecek lagi kayak if (user?.umur != null) di setiap function. Kalau udh sampe dalem mustinya data udah diparse dengan bentuk yang benar. Perbatasan ini juga bisa dibayangin dengan IO layer. Apapun yang nyentuh IO kita engga bisa percayain. Jadi selalu parse.

Enaknya, dunia dalam jadi tempat yang pasti. Semua function nya cuma nerima state valid dan ngembaliin state valid. Gampang di test, gampang di mengerti, karena semuanya deterministic.

Dan ini nyambung balik ke tweet Om Didiet di awal. Command monad itu ide yang sama dibawa lebih jauh lagi: efek ke dunia luar (nulis database, manggil API) engga langsung dieksekusi, tapi direpresentasikan sebagai data dulu. Logic program nya pure, cuma ngitung "command apa yang harus dijalanin", dan eksekusinya baru terjadi di pinggir program (bukan dipinggir jurang). Jadi bukan cuma data masuk yang dijaga di perbatasan, data keluar pun sama. Kalau di haskell hal ini dipaksa sama bahasanya, di bahasa lain kita yang harus disiplin sendiri.

Coding Mental Model

Jadi kalau dirangkum, mental model gw saat ngoding itu gini:

Program = state + function yang mengubah state. Engga peduli itu web app, game, atau compiler. Semua nya cuma data di satu titik waktu, dan transisi ke titik berikutnya (mengikuti mandat Von Neuman).

Dari satu kalimat itu, banyak hal jadi keliatan beda:

  • Baca kode = Ngelacak statenya kemana dan berubah dimana. Kode yang susah dibaca biasanya karena state nya nyebar dan bisa diubah dari mana-mana.
  • Debugging = Nyari titik dimana state jadi engga valid. Makin kecil ruang state, makin dikit tempat yang perlu dicurigain.
  • Desain yang baik = Memperkecil ruang state (tipe), memperjelas transisinya (function), dan menjaga perbatasan (parse di boundary).

Makanya kalau gw stuck di bug atau lagi bingung design, pertanyaan yang gw tanya selalu sama:

State apa aja yang mungkin terjadi disini, dan siapa aja yang boleh ngubahnya?

Kalau jawabannya "banyak banget dan dari mana aja", ya disitu masalahnya lol. Bukan di logic nya, tapi di ruang state nya yang kegedean. Perkecil dulu ruang state nya, biasanya logic nya beres sendiri (ya idealnya dan berharapnya gt).

Comments

Comments section - you can integrate Giscus or another commenting system here.