Jangan memvalidasi, lakukan parsing — dari bahasa yang tidak menginginkannya seperti TypeScript
(cekrem.github.io)- 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
EmailAddressbisa dipercaya oleh sisa program - Dalam TypeScript yang memakai sistem tipe struktural,
stringdanEmailtidak terpisah secara alami, sehingga branded type berbasisunique symboldan asersiasyang dibatasi dipakai untuk meniru batas nominal - Discriminated union seperti
Parsed<T>menampilkan keberhasilan dan kegagalan di signature tipe, tetapi karena tidak ada ekspresimatchkhusus, exhaustive check denganneverharus 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 meskipunisValidUser(user): booleanlolos, TypeScript tidak bisa mengingat fakta itu - Setelahnya, di kode seperti
emailService.send(user.email, ...),user.emailtetap 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
sendWelcomenilai harus lebih dulu lolos parser, dan di dalam fungsi tidak perlu validasi ulang atauifdefensif 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
stringadalahstring, dan tidak ada fitur untuk benar-benar membuat tipe berbeda sepertinewtypedi 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 symbolyang tidak diekspor keluar modul sebagai key brand
- Cara sederhana adalah phantom field string literal seperti
- 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
Emaildanstringdiperlakukan berbeda pada waktu kompilasi - Brand hanya bekerja satu arah
Emailbisa di-assign kestringstringbiasa tidak bisa langsung masuk keEmail
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 denganraw as Email- Asersi
as Emailadalah pengecualian yang diizinkan karena parser merupakan trust boundary- Jika bagian lain di codebase mengasert
stringmenjadiEmail, desainnya runtuh - Parser bisa ditempatkan di modul terpisah, dan jika asersi brand muncul di luar sana itu bisa diperlakukan sebagai bug
- Jika bagian lain di codebase mengasert
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
parseEmailsengaja dibuat tipis; parser email nyata perlu menangani trim, lowercase, validasi domain, dan lain-lain
Memisahkan input mentah dan tipe domain tepercaya
- Dengan memisahkan
UnvalidatedUserdanValidUser, nilai yang datang dari jaringan atau input eksternal bisa dibedakan dengan jelas dari nilai yang tepercaya di domainUnvalidatedUsermenaruhid,email,agesebagaiunknownValidUsermemakai branded type sepertiUserId,Email,Age
- Jika
UserIdjuga diberi brand, kesalahan seperti mengirimOrderIdke tempat yang membutuhkanUserIdbisa dicegah parseUser(raw: unknown): Parsed<ValidUser>mempersempit input mentah secara bertahap- Memeriksa apakah input adalah objek
- Memeriksa keberadaan field
id,email,age - Memeriksa apakah
emailadalah string - Memanggil
parseUserId,parseEmail,parseAgemasing-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 Emaildi 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
matchkhusus - Di
defaultpadaswitch, pola seperticonst _exhaustive: never = resultharus ditulis sendiri - Jika varian ketiga ditambahkan ke
Parsed, assignment keneverakan gagal dan compiler memberi tahu lokasinya
- Discriminated union di TypeScript sangat kuat untuk gaya ini, tetapi tidak ada ekspresi
satisfiesbisa dipakai sebagai escape hatch yang lebih sopan daripada castconst x = { ... } satisfies Configmemeriksa tipe sambil tidak melebarkan literal type secara tidak perlu
JSON.parsemengembalikanany, jadi lebih aman jika langsung dianotasi sebagaiunknown- Terima dalam bentuk
const raw: unknown = JSON.parse(input), lalu biarkan parser menentukan apakah itu tipe domain JSON.parsebukan validator, melainkan tahap deserialisasi yang mengubah byte menjadi nilai JS
- Terima dalam bentuk
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)mengembalikandatasaat sukses danerrorsaat 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
Useryang datang dari jaringan bukanUserdomain 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
unknowndari input eksternal dan tipe domain yang tepercaya
- Tidak selalu tepat mengubah semua kode menjadi pipeline parsing, tetapi jika
ifdefensif yang sama berulang di banyak file, itu adalah sinyal bahwa informasi yang seharusnya divalidasi belum berhasil dimasukkan ke dalam tipe
1 komentar
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
Kebanyakan orang memang tidak punya pilihan atau waktu untuk begitu saja memilih 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
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 mau, nilai di dalam TypedArray juga bisa dibuat dengan cara yang sama
TArray<Foo, MyEnum>. Tapi ini cerita tentang C++Library
stdmilik Zig punya EnumArray yang diimplementasikan dengancomptime. 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