1 poin oleh GN⁺ 4 jam lalu | 1 komentar | Bagikan ke WhatsApp
  • Jika pengecekan seperti if (user.email) tersebar di kode TypeScript, fakta yang sudah diperiksa tidak tersisa di dalam tipe, sehingga di bagian belakang call stack kondisi yang sama terus dicurigai lagi
  • Parser menerima input mentah lalu mengembalikan tipe yang lebih sempit atau informasi kegagalan, sehingga fakta yang sudah tervalidasi seperti EmailAddress bisa dipercaya oleh sisa program
  • Dalam TypeScript yang memakai sistem tipe struktural, string dan Email tidak terpisah secara alami, sehingga branded type berbasis unique symbol dan asersi as yang dibatasi dipakai untuk meniru batas nominal
  • Discriminated union seperti Parsed<T> menampilkan keberhasilan dan kegagalan di signature tipe, tetapi karena tidak ada ekspresi match khusus, exhaustive check dengan never harus ditulis sendiri
  • Zod, io-ts, dan valibot dapat membuat parser dan tipe TypeScript dari skema yang sama, tetapi disiplin untuk melakukan parsing di setiap boundary sebelum menganggap input eksternal sebagai tipe domain tetap menjadi tanggung jawab developer

Validasi membuang informasi, parsing menyimpannya di tipe

  • Prinsip Parse, don’t validate dari Alexis King menempatkan perbedaan antara validator dan parser sebagai inti
    • Validator memutuskan “nilai ini tidak masalah” lalu meneruskan alur lewat boolean atau exception
    • Parser menerima input mentah lalu membuat tipe yang lebih presisi atau mengembalikan alasan kegagalan
  • Jika tipe tetap lebar seperti User.email: string, User.age: number, maka meskipun isValidUser(user): boolean lolos, TypeScript tidak bisa mengingat fakta itu
  • Setelahnya, di kode seperti emailService.send(user.email, ...), user.email tetap hanya string biasa yang bisa berupa string kosong, "hello", atau "definitely not an email"
  • Alur yang memeriksa ulang kondisi yang sama di banyak tempat lebih dekat dengan shotgun parsing seperti yang disebut King

API di mana tipe itu sendiri menjadi bukti

  • Bentuk yang diinginkan adalah signature fungsi seperti sendWelcome(user: ValidUser) yang hanya bisa menerima nilai yang sudah diparsing
  • Dalam struktur ini, sebelum memanggil sendWelcome nilai harus lebih dulu lolos parser, dan di dalam fungsi tidak perlu validasi ulang atau if defensif tambahan
  • Di Elm hal ini bisa ditangani dengan sederhana lewat opaque type dan smart constructor, tetapi di TypeScript dibutuhkan lebih banyak perangkat untuk mendapatkan efek yang sama

Membuat batas nominal dengan branded type

  • TypeScript menggunakan sistem tipe struktural, sehingga tipe dengan shape yang sama dianggap sebagai tipe yang sama
    • string adalah string, dan tidak ada fitur untuk benar-benar membuat tipe berbeda seperti newtype di Haskell
  • Solusi memutar yang dipakai komunitas adalah branding atau tagging
    • Cara sederhana adalah phantom field string literal seperti { readonly __brand: "Email" }
    • Cara yang lebih kuat adalah memakai unique symbol yang tidak diekspor keluar modul sebagai key brand
  • Contoh tipenya berbentuk type Email = string & { readonly [EmailBrand]: true }, type Age = number & { readonly [AgeBrand]: true }
  • Field brand adalah marker di level tipe yang tidak ada saat runtime, dan membuat Email dan string diperlakukan berbeda pada waktu kompilasi
  • Brand hanya bekerja satu arah
    • Email bisa di-assign ke string
    • string biasa tidak bisa langsung masuk ke Email

Parser hanya mengizinkan asersi di trust boundary

  • parseEmail(raw: string): Parsed<Email> mengembalikan kegagalan jika string tidak memiliki @, dan jika lolos akan membuat tipe brand dengan raw as Email
  • Asersi as Email adalah pengecualian yang diizinkan karena parser merupakan trust boundary
    • Jika bagian lain di codebase mengasert string menjadi Email, desainnya runtuh
    • Parser bisa ditempatkan di modul terpisah, dan jika asersi brand muncul di luar sana itu bisa diperlakukan sebagai bug
  • Parsed<T> pada contoh berbentuk { kind: "ok"; value: T } | { kind: "err"; error: ParseError }
    • Kegagalan tidak disembunyikan dalam exception, tetapi muncul di signature tipe
    • Jika memakai pembeda string seperti kind: "ok" | "err", narrowing tipe setelahnya akan bekerja lebih jujur saat varian baru ditambahkan
  • Contoh parseEmail sengaja dibuat tipis; parser email nyata perlu menangani trim, lowercase, validasi domain, dan lain-lain

Memisahkan input mentah dan tipe domain tepercaya

  • Dengan memisahkan UnvalidatedUser dan ValidUser, nilai yang datang dari jaringan atau input eksternal bisa dibedakan dengan jelas dari nilai yang tepercaya di domain
    • UnvalidatedUser menaruh id, email, age sebagai unknown
    • ValidUser memakai branded type seperti UserId, Email, Age
  • Jika UserId juga diberi brand, kesalahan seperti mengirim OrderId ke tempat yang membutuhkan UserId bisa dicegah
  • parseUser(raw: unknown): Parsed<ValidUser> mempersempit input mentah secara bertahap
    • Memeriksa apakah input adalah objek
    • Memeriksa keberadaan field id, email, age
    • Memeriksa apakah email adalah string
    • Memanggil parseUserId, parseEmail, parseAge masing-masing, lalu segera mengembalikan jika gagal
    • Jika semuanya berhasil, mengembalikan ValidUser
  • Pendekatan ini lebih bertele-tele dibanding F# atau Elm, tetapi membuat sendWelcome(user: ValidUser) benar-benar aman

Bagian-bagian TypeScript yang terasa mengganjal

  • Gesekan pertama adalah asersi as Email di dalam parser
    • Di bahasa dengan tipe nominal sungguhan, smart constructor bisa mengembalikan tipe baru tanpa harus “berbohong”
    • Brand di TypeScript hanyalah marker tipe virtual, jadi parser harus melangkah lewat asersi
  • Gesekan kedua adalah exhaustive check
    • Discriminated union di TypeScript sangat kuat untuk gaya ini, tetapi tidak ada ekspresi match khusus
    • Di default pada switch, pola seperti const _exhaustive: never = result harus ditulis sendiri
    • Jika varian ketiga ditambahkan ke Parsed, assignment ke never akan gagal dan compiler memberi tahu lokasinya
  • satisfies bisa dipakai sebagai escape hatch yang lebih sopan daripada cast
    • const x = { ... } satisfies Config memeriksa tipe sambil tidak melebarkan literal type secara tidak perlu
  • JSON.parse mengembalikan any, jadi lebih aman jika langsung dianotasi sebagai unknown
    • Terima dalam bentuk const raw: unknown = JSON.parse(input), lalu biarkan parser menentukan apakah itu tipe domain
    • JSON.parse bukan validator, melainkan tahap deserialisasi yang mengubah byte menjadi nilai JS

Pengulangan yang dikurangi oleh library seperti Zod

  • Zod, io-ts, dan valibot menyediakan pola yang sama dengan cara yang lebih nyaman daripada parser yang ditulis tangan
  • Contoh Zod membuat parser dan tipe TypeScript bersama-sama dari satu skema
    • z.object({ id: z.number().int(), email: z.string().email().brand<"Email">(), age: z.number().int().min(0).max(150).brand<"Age">() })
    • Mendapatkan tipe dengan z.infer<typeof ValidUserSchema>
    • ValidUserSchema.safeParse(rawInput) mengembalikan data saat sukses dan error saat gagal
  • .brand() di Zod juga merupakan fitur level tipe seperti brand symbol buatan tangan, dan tidak punya perilaku runtime
  • Library mengikat parser dan tipe dalam definisi yang sama sehingga boundary lebih mudah dijaga, tetapi tidak otomatis memaksakan disiplin bahwa itu harus dipakai di semua boundary eksternal
  • User yang datang dari jaringan bukan User domain sebelum diparsing, dan godaan untuk melewati pesan error dengan asersi tipe harus dihindari

Membawa bukti ke dalam tipe, bukan ke ingatan

  • Prinsip kecilnya adalah “biarkan sistem tipe membawa buktinya, jangan serahkan pada ingatan manusia”
  • Jika suatu kondisi diperiksa tetapi hasilnya tidak dienkodekan ke dalam tipe, kode setelahnya akan mudah mengasumsikan bahwa validasi itu sudah selesai
  • Di TypeScript, prinsip ini diwujudkan dengan mengandalkan tiga alat
    • Branded type untuk meniru identitas nominal
    • Discriminated union untuk menampilkan keberhasilan dan kegagalan
    • Boundary yang ketat antara unknown dari input eksternal dan tipe domain yang tepercaya
  • Tidak selalu tepat mengubah semua kode menjadi pipeline parsing, tetapi jika if defensif yang sama berulang di banyak file, itu adalah sinyal bahwa informasi yang seharusnya divalidasi belum berhasil dimasukkan ke dalam tipe

1 komentar

 
GN⁺ 4 jam lalu
Opini di Lobste.rs
  • Jika gaya penulisan kode dan aspek teknis maupun ergonomis yang diinginkan JavaScript/TypeScript bertabrakan, rasanya tinggal pakai saja salah satu dari sekian banyak bahasa yang dikompilasi ke JS
    Haskell, Elm, dan F# disebutkan, dan masih banyak bahasa lain yang lebih ingin dipakai penulis seperti PureScript, js_of_ocaml, Reason, LunarML, dan sebagainya. Penulis bahkan menulis artikel Why TypeScript Won’t Save You untuk membandingkannya lebih jauh dengan bahasa-bahasa favoritnya, dan juga mengelola https://learnelm.dev.
    Atau mungkin tujuan utamanya memang perbandingan, jadi ingin menunjukkan bahwa TypeScript dalam banyak kasus tidak cukup, lalu mendorong adopsi toolchain atau ide lain

    • Ada kendala seperti codebase yang sudah ada, tingkat kemahiran tim pada bahasa tertentu atau kebijakan perusahaan, serta dukungan, tool, dan skala komunitas yang lebih kecil
      Kebanyakan orang memang tidak punya pilihan atau waktu untuk begitu saja memilih bahasa lain
    • Biasanya karena sudah ada codebase TypeScript yang besar, atau karena mereka memakai library TypeScript yang tidak ada di bahasa lain
  • Di pekerjaan, saya sangat menyukai branded type, tetapi sangat mengganggu bahwa kita tidak bisa membuat Array atau TypedArray yang hanya bisa diindeks dengan angka ber-brand
    TypedArray bahkan tidak bisa menyimpan angka ber-brand, atau lebih tepatnya, bahkan tidak bisa membacanya kembali. Saya benar-benar berharap fitur seperti ini ada, meskipun mungkin perlu satu set tipe terpisah seperti IndexArray atau IndexTypedArray

    • Saya juga suka branded type, tetapi setelah dibahas, banyak orang menganggap hasilnya tidak sebanding dengan usaha yang dikeluarkan
      Jika kita memakai branded type untuk semua ID dalam skema database yang cukup kompleks, TypeScript akan menangkapnya saat kita membuat join atau kondisi yang tidak masuk akal. Signature fungsi juga jadi lebih jelas dan berbagai kesalahan jadi lebih sulit dibuat
    • Kalau cukup rela berbohong dengan tegas, Array yang hanya bisa diindeks dengan angka ber-brand sebenarnya bisa dibuat
      Kalau mau, nilai di dalam TypedArray juga bisa dibuat dengan cara yang sama
    • Di tempat kerja, kami memakai “smart enum” dan tipe array kustom sehingga bisa ditulis seperti TArray<Foo, MyEnum>. Tapi ini cerita tentang C++
      Library std milik Zig punya EnumArray yang diimplementasikan dengan comptime. Ini bisa memakai enum padat maupun enum jarang sebagai indeks, menghitung indexer yang benar saat waktu kompilasi, dan juga menyediakan fitur yang lebih luas.
      Saya makin menyukai pengetikan tipe yang presisi seperti ini. Ini sangat membantu mencegah bug logika masuk ke dalam codebase sejak awal