Doğrulama Yapma, Parse Et — TypeScript Gibi Bunu İstemeyen Bir Dilde
(cekrem.github.io)- TypeScript kodunda
if (user.email)gibi kontroller dağınık halde bulunursa, doğrulanmış olan gerçek tipte kalmadığı için çağrı yığınının ilerleyen kısımlarında aynı koşuldan sürekli şüphe edilir - Parser, ham girdiyi alıp daha dar bir tip ya da hata bilgisi döndürür;
EmailAddressgibi doğrulanmış bir gerçeği programın geri kalanının güvenle kullanmasını sağlar - Yapısal tip sistemi kullanan TypeScript’te
stringileEmaildoğal olarak ayrışmadığından,unique symboltabanlı branded type ve sınırlıasassertion kullanarak nominal sınırlar taklit edilir Parsed<T>gibi discriminated union’lar başarı ve başarısızlığı tip imzasında görünür kılar; ancak özel birmatchifadesi olmadığı içinneverkullanarak exhaustive check’i elle yazmak gerekir- Zod, io-ts, valibot şemadan parser ve TypeScript tipini birlikte üretebilir; fakat dış girdiyi domain tipi olarak görmeden önce her sınırda parse etme disiplini hâlâ geliştiriciye kalır
Doğrulama bilgiyi atar, parse etme tipte bırakır
- Alexis King’in Parse, don’t validate ilkesi, doğrulayıcı ile parser arasındaki farkı merkeze alır
- Doğrulayıcı “bu değer uygun” diye karar verdikten sonra akışı boolean ya da exception ile devreder
- Parser ham girdiyi alıp daha kesin bir tip üretir ya da başarısızlık nedenini döndürür
User.email: string,User.age: numbergibi tipler geniş kaldığında,isValidUser(user): booleangeçse bile TypeScript bu gerçeği hatırlamaz- Sonrasında
emailService.send(user.email, ...)gibi kodlardauser.emailhâlâ boş string,"hello","definitely not an email"gibi genel bir string’dir - Aynı koşulu birçok yerde yeniden kontrol eden akış, King’in sözünü ettiği shotgun parsing’e yakındır
Tipin kendisinin kanıt olduğu API
- İstenen biçim,
sendWelcome(user: ValidUser)gibi yalnızca parse edilmiş değerleri kabul eden bir fonksiyon imzasıdır - Bu yapıda
sendWelcomeçağrılmadan önce mutlaka parser’dan geçilmelidir; fonksiyon içinde ayrıca yeniden doğrulama ya da savunmacıifgerekmez - Elm’de opaque type ve smart constructor ile bu kolayca çözülebilir; TypeScript’te ise aynı etki için daha fazla mekanizma gerekir
Branded type ile nominal sınırlar oluşturmak
- TypeScript yapısal tip sistemi kullanır; bu nedenle aynı shape’e sahip tipler aynı tip kabul edilir
stringbirstring’dir; Haskell’dekinewtypegibi gerçekten farklı bir tip oluşturan bir özellik yoktur
- Toplulukta kullanılan dolaylı çözüm branding ya da etiketlemedir
- Basit yöntem,
{ readonly __brand: "Email" }gibi string literal bir phantom field kullanmaktır - Daha güçlü yöntem, modül dışına export edilmeyen bir
unique symbol’ü brand anahtarı olarak kullanmaktır
- Basit yöntem,
- Örnek tipler
type Email = string & { readonly [EmailBrand]: true },type Age = number & { readonly [AgeBrand]: true }biçimindedir - Brand alanı runtime’da var olmayan tip düzeyinde bir işaretçidir;
Emaililestring’in compile time’da farklı ele alınmasını sağlar - Brand yalnızca tek yönde çalışır
Email,string’e atanabilir- Sıradan bir
string, doğrudanEmailolarak gelemez
Parser, yalnızca güven sınırında assertion’a izin verir
parseEmail(raw: string): Parsed<Email>, string içinde@yoksa başarısızlık döndürür; geçerseraw as Emailile branded type üretiras Emailassertion’ı, parser bir güven sınırı olduğu için izin verilen bir istisnadır- Kod tabanının başka bir yerinde
string’iEmailolarak assert etmek tasarımı bozar - Parser ayrı bir modüle konup brand assertion’ı bunun dışında görülürse bug kabul edilebilir
- Kod tabanının başka bir yerinde
- Örnekteki
Parsed<T>biçimi{ kind: "ok"; value: T } | { kind: "err"; error: ParseError }şeklindedir- Başarısızlık exception içinde saklanmaz, tip imzasında görünür
kind: "ok" | "err"gibi string discriminant kullanıldığında, ileride varyant eklendiğinde type narrowing daha dürüst çalışır
parseEmailörneği bilerek incedir; gerçek bir e-posta parser’ı trim, lowercase, domain doğrulaması gibi daha fazla işlemi ele almalıdır
Ham girdi ile güvenilen domain tiplerini ayırmak
UnvalidatedUserileValidUserayrılırsa ağdan ya da dış girdiden gelen değerler ile domain içinde güvenilebilecek değerler net biçimde ayrılırUnvalidatedUser,id,email,agealanlarınıunknownolarak tutarValidUser,UserId,Email,Agegibi branded type’lar kullanır
UserIdde brand edilirseUserIdgereken yereOrderIdgibi başka bir ID’nin yanlışlıkla verilmesi engellenebilirparseUser(raw: unknown): Parsed<ValidUser>ham girdiyi aşamalı olarak daraltır- Girdinin object olup olmadığını kontrol eder
id,email,agealanlarının varlığını kontrol ederemail’in string olup olmadığını kontrol ederparseUserId,parseEmail,parseAgefonksiyonlarını ayrı ayrı çağırır ve başarısızlıkta hemen döner- Hepsi başarılı olursa
ValidUserdöndürür
- Bu yaklaşım F# ya da Elm’e göre daha uzun sözdizimlidir; ancak
sendWelcome(user: ValidUser)gerçekten güvenli hâle gelir
TypeScript’in pürüz çıkardığı noktalar
- İlk sürtünme, parser içindeki
as Emailassertion’ıdır- Gerçek nominal tipli dillerde smart constructor yalan söylemeden yeni tipi döndürebilir
- TypeScript’in brand’leri hayalî tip işaretçileri olduğundan parser’ın assertion’a geçmesi gerekir
- İkinci sürtünme exhaustive check’tir
- TypeScript’in discriminated union’ları bu stilde güçlüdür; fakat özel bir
matchifadesi yoktur switchiçindekidefaultbölümündeconst _exhaustive: never = resultgibi pattern’leri elle kullanmak gerekirParsed’a üçüncü bir varyant eklenirseneverataması başarısız olur ve compiler konumu bildirir
- TypeScript’in discriminated union’ları bu stilde güçlüdür; fakat özel bir
satisfies, cast’e göre daha nazik bir escape hatch olarak kullanılabilirconst x = { ... } satisfies Config, tipi kontrol ederken literal type’ları gereksiz yere genişletmez
JSON.parseanydöndürdüğü için hemenunknownolarak anotasyonlamak daha güvenlidirconst raw: unknown = JSON.parse(input)biçiminde alınıp, sonrasında parser bunun domain tipi olup olmadığına karar verirJSON.parsebir doğrulayıcı değil, byte’ları JS değerine dönüştüren bir deserialization aşamasıdır
Zod gibi kütüphanelerin azalttığı tekrar
- Zod, io-ts, valibot, elle yazılmış parser’dan daha kullanışlı biçimde aynı pattern’i sunar
- Zod örneği, tek bir şemadan parser ve TypeScript tipini birlikte üretir
z.object({ id: z.number().int(), email: z.string().email().brand<"Email">(), age: z.number().int().min(0).max(150).brand<"Age">() })- Tip,
z.infer<typeof ValidUserSchema>ile elde edilir ValidUserSchema.safeParse(rawInput)başarı durumunda data, başarısızlıkta error döndürür
- Zod’un
.brand()özelliği de elle yapılmış symbol brand gibi tip düzeyinde bir özelliktir; runtime davranışı yoktur - Kütüphane, parser ile tipi aynı tanımda birleştirerek sınırları korumayı kolaylaştırır; fakat bunun tüm dış sınırlarda kullanılması gerektiği disiplinini geliştirici yerine zorunlu kılmaz
- Ağdan gelen
User, parse edilene kadar domainUserdeğildir; tip assertion ile hata mesajını baypas etme cazibesinden kaçınmak gerekir
Kanıtı hafızada değil, tipte taşımak
- Küçük ilke şudur: “Tip sisteminin kanıtı taşımasını sağla, insan hafızasına bırakma”
- Bir koşulu kontrol edip sonucunu tipe encode etmezseniz, sonraki kod bu doğrulamanın çoktan bittiğini kolayca varsayar
- TypeScript’te bu ilke üç araca dayanarak uygulanır
- Nominal kimliği taklit eden branded type’lar
- Başarı ve başarısızlığı görünür kılan discriminated union’lar
- Dış girdinin
unknownolması ile güvenilen domain tipleri arasındaki sıkı sınır
- Tüm kodu parsing pipeline’a dönüştürmek her zaman uygun değildir; ancak aynı savunmacı
ifbirçok dosyada tekrarlanıyorsa bu, doğrulanması gereken bilginin tipe taşınamadığının işaretidir
1 yorum
Lobste.rs yorumları
JavaScript/TypeScript’in istediği kod stiliyle teknik ve ergonomik olarak çakışıyorsa, JS’ye derlenen dillerden birini kullanmak yeterli olmaz mı diye düşünüyorum.
Haskell, Elm, F# anılıyor; ayrıca PureScript, js_of_ocaml, Reason, LunarML gibi yazarın daha çok kullanmak isteyeceği çizgide pek çok dil de var. Yazar Why TypeScript Won’t Save You başlıklı bir yazı bile yazıp tercih ettiği dillerle daha fazla karşılaştırma yapmış ve https://learnelm.dev sitesini de işletiyor.
Ya da belki amaç zaten karşılaştırmanın kendisidir; TypeScript’in çoğu durumda yeterli olmadığını gösterip başka toolchain’lerin ya da fikirlerin benimsenmesini teşvik etmek istiyor olabilir
Çoğu kişinin başka bir dil seçme özgürlüğü ya da zamanı yok
İşte branded type’ları çok seviyorum ama yalnızca branded sayılarla indekslenebilen bir Array ya da TypedArray oluşturamamak gerçekten can sıkıcı.
TypedArray branded sayıları saklayamıyor; daha doğrusu onları okuyup çıkarmak bile mümkün değil. IndexArray veya IndexTypedArray gibi ayrı bir tip kümesi gerekecek olsa bile, böyle bir özelliğin mutlaka olmasını isterdim
Oldukça karmaşık bir veritabanı şemasında tüm ID’ler için branded type kullanırsanız, mantıksız join’ler ya da koşullar oluşturduğunuzda TypeScript bunu yakalıyor. Fonksiyon imzaları da daha netleşiyor ve çeşitli hatalar yapmak zorlaşıyor
İsterseniz TypedArray değerleri için de aynı şekilde yapmak mümkün
TArray<Foo, MyEnum>gibi yazabiliyoruz. Ancak bu C++ için geçerli.Zig’in
stdkütüphanesindecomptimeile uygulanmış EnumArray var. Yoğun enum’ları ya da seyrek enum’ları indeksleme için kullanma, derleme zamanında doğru indeksleyiciyi hesaplama gibi daha geniş özellikler de sunuyor.Bu tür hassas tipleme giderek daha çok hoşuma gidiyor. Kod tabanına mantık hatalarının girmesini baştan büyük ölçüde engelliyor