वैलिडेट मत करो, पार्स करो — TypeScript जैसी अनचाही भाषाओं में
(cekrem.github.io)- अगर TypeScript कोड में
if (user.email)जैसी जाँचें बिखर जाती हैं, तो जो तथ्य पहले ही जाँचा जा चुका है वह type में नहीं बचता, इसलिए call stack के बाद के हिस्सों में उसी शर्त पर बार-बार संदेह होता रहता है - parser raw input लेकर ज़्यादा संकीर्ण type या failure information लौटाता है, जिससे
EmailAddressजैसे सत्यापित तथ्य पर प्रोग्राम का बाकी हिस्सा भरोसा कर सकता है - structural type system इस्तेमाल करने वाले TypeScript में
stringऔरEmailस्वाभाविक रूप से अलग नहीं होते, इसलिएunique symbolआधारित branded types और सीमितasassertion से nominal boundary की नकल की जाती है Parsed<T>जैसे discriminated union success और failure को type signature में दिखाते हैं, लेकिन dedicatedmatchexpression न होने सेneverका इस्तेमाल कर exhaustive check खुद लिखना पड़ता है- Zod, io-ts, valibot schema से parser और TypeScript type साथ बना सकते हैं, लेकिन external input को domain type मानने से पहले हर boundary पर parsing करने का अनुशासन अब भी developer पर ही रहता है
वैलिडेशन जानकारी फेंक देता है, parsing उसे type में बचाकर रखती है
- Alexis King के Parse, don’t validate सिद्धांत का केंद्र validator और parser का फर्क है
- validator यह तय करता है कि “यह value ठीक है” और फिर boolean या exception के जरिए flow आगे बढ़ा देता है
- parser raw input लेकर उससे ज़्यादा सटीक type बनाता है या failure की वजह लौटाता है
- अगर
User.email: string,User.age: numberकी तरह type चौड़े ही बने रहें, तोisValidUser(user): booleanपास होने पर भी TypeScript इस तथ्य को याद नहीं रख पाता - बाद में
emailService.send(user.email, ...)जैसे कोड मेंuser.emailअब भी खाली string,"hello","definitely not an email"जैसी साधारण string ही रहती है - एक ही शर्त को कई जगह दोबारा जाँचने वाला flow, King के कहे shotgun parsing के क़रीब है
जब type खुद सबूत बन जाए, ऐसा API
- वांछित रूप
sendWelcome(user: ValidUser)जैसी function signature है, जो सिर्फ parsed value ही स्वीकार करे - इस संरचना में
sendWelcomeको call करने से पहले parser से गुजरना अनिवार्य है, और function के भीतर अलग से re-validation या defensiveifकी ज़रूरत नहीं पड़ती - Elm में opaque type और smart constructor से यह आसानी से हो जाता है, लेकिन TypeScript में वही असर पाने के लिए ज़्यादा साधन लगाने पड़ते हैं
branded types से nominal boundary बनाना
- TypeScript structural type system इस्तेमाल करता है, इसलिए एक ही shape वाले type को एक ही type माना जाता है
stringतोstringही है, और Haskell केnewtypeजैसी सचमुच अलग type बनाने की सुविधा नहीं है
- community में इस्तेमाल होने वाला workaround है branding या tagging
- सरल तरीका है
{ readonly __brand: "Email" }जैसी string literal phantom field - ज़्यादा मज़बूत तरीका है module के बाहर export न किया गया
unique symbolbrand key के रूप में इस्तेमाल करना
- सरल तरीका है
- उदाहरण type का रूप
type Email = string & { readonly [EmailBrand]: true },type Age = number & { readonly [AgeBrand]: true }है - brand field runtime में मौजूद नहीं होती, यह type-level marker है जो compile time पर
Emailऔरstringको अलग तरह से treat कराती है - brand सिर्फ एक दिशा में काम करता है
Email,stringको assign किया जा सकता है- सामान्य
stringसीधेEmailमें नहीं आ सकती
parser भरोसे की boundary पर ही assertion की अनुमति देता है
parseEmail(raw: string): Parsed<Email>string में@न होने पर failure लौटाता है, और पास होने परraw as Emailसे branded type बनाता हैas Emailassertion इसलिए स्वीकार्य अपवाद है क्योंकि parser ही trust boundary है- अगर codebase के किसी और हिस्से में
stringकोEmailमानकर assert किया जाए तो design टूट जाता है - parser को अलग module में रखकर, उसके बाहर brand assertion दिखे तो उसे bug माना जा सकता है
- अगर codebase के किसी और हिस्से में
- उदाहरण का
Parsed<T>रूप{ kind: "ok"; value: T } | { kind: "err"; error: ParseError }है- failure exception के रूप में छिपी नहीं रहती, बल्कि type signature में दिखती है
kind: "ok" | "err"जैसे string discriminator इस्तेमाल करने पर बाद में variant बढ़ने पर type narrowing ज़्यादा ईमानदारी से काम करती है
parseEmailका उदाहरण जानबूझकर हल्का रखा गया है; असली email parser को trim, lowercase, domain validation जैसी और चीज़ें भी संभालनी होंगी
raw input और trusted domain type को अलग रखना
UnvalidatedUserऔरValidUserको अलग करने से network या external input से आए value और domain में भरोसेमंद value के बीच साफ़ अंतर हो जाता हैUnvalidatedUserमेंid,email,ageकोunknownरखा जाता हैValidUserमेंUserId,Email,Ageजैसे branded type इस्तेमाल होते हैं
UserIdको भी brand करने परUserIdचाहिए जहाँ वहाँ गलती सेOrderIdजैसा कोई और ID देने से बचा जा सकता हैparseUser(raw: unknown): Parsed<ValidUser>raw input को चरणबद्ध तरीके से संकीर्ण करता है- पहले जाँचता है कि input object है या नहीं
- फिर
id,email,agefield की मौजूदगी जाँचता है emailstring है या नहीं, यह जाँचता हैparseUserId,parseEmail,parseAgeको अलग-अलग call करता है और failure होने पर तुरंत लौट जाता है- सब सफल हों तो
ValidUserलौटाता है
- यह तरीका F# या Elm से ज़्यादा verbose है, लेकिन
sendWelcome(user: ValidUser)को वास्तव में सुरक्षित बना देता है
TypeScript जहाँ चुभता है
- पहली friction parser के भीतर
as Emailassertion है- असली nominal type language में smart constructor बिना झूठ बोले नया type लौटा सकता है
- TypeScript का brand एक काल्पनिक type marker है, इसलिए parser को assertion के सहारे आगे बढ़ना पड़ता है
- दूसरी friction exhaustive check है
- TypeScript का discriminated union इस शैली में बहुत ताकतवर है, लेकिन dedicated
matchexpression नहीं है switchकेdefaultमेंconst _exhaustive: never = resultजैसा pattern खुद लिखना पड़ता है- अगर
Parsedमें तीसरा variant जुड़ जाए, तोneverassignment fail होगी और compiler जगह बता देगा
- TypeScript का discriminated union इस शैली में बहुत ताकतवर है, लेकिन dedicated
satisfiesको cast से ज़्यादा शालीन escape hatch की तरह इस्तेमाल किया जा सकता हैconst x = { ... } satisfies Configtype को जाँचता भी है और literal type को बेवजह चौड़ा भी नहीं करता
JSON.parseanyलौटाता है, इसलिए उसे तुरंतunknownके रूप में annotate करना ज़्यादा सुरक्षित हैconst raw: unknown = JSON.parse(input)के रूप में लें, फिर parser तय करे कि यह domain type है या नहींJSON.parsevalidator नहीं, बल्कि bytes को JS value में बदलने वाला deserialization चरण है
Zod जैसी libraries जो दोहराव कम करती हैं
- Zod, io-ts, valibot हाथ से लिखे parser की तुलना में यही pattern ज़्यादा सुविधाजनक ढंग से देती हैं
- Zod का उदाहरण एक ही schema से parser और TypeScript type दोनों बनाता है
z.object({ id: z.number().int(), email: z.string().email().brand<"Email">(), age: z.number().int().min(0).max(150).brand<"Age">() })z.infer<typeof ValidUserSchema>से type मिलता हैValidUserSchema.safeParse(rawInput)success पर data और failure पर error लौटाता है
- Zod का
.brand()भी हाथ से बनाए symbol brand की तरह type-level feature है; इसका runtime behavior नहीं होता - library parser और type को एक ही definition में बाँधकर boundary बनाए रखना आसान करती है, लेकिन हर external boundary पर इसे इस्तेमाल करना है, यह अनुशासन अपने आप लागू नहीं करती
- network से आया
User, parse होने तक domainUserनहीं है, और error message से बचने के लिए type assertion का शॉर्टकट लेने के प्रलोभन से बचना चाहिए
सबूत को याददाश्त में नहीं, type में रखो
- छोटा-सा सिद्धांत यह है कि “type system को सबूत साथ रखने दो, इसे इंसानी याददाश्त पर मत छोड़ो”
- अगर किसी शर्त को जाँचकर उसका नतीजा type में encode नहीं किया गया, तो बाद का code आसानी से मान लेता है कि validation पहले ही हो चुकी है
- TypeScript में यह सिद्धांत तीन औज़ारों के सहारे लागू होता है
- nominal identity की नकल करने वाले branded types
- success और failure को दिखाने वाले discriminated union
- external input के
unknownऔर trusted domain type के बीच सख्त boundary
- हर code को parsing pipeline में बदलना हमेशा सही नहीं होता, लेकिन अगर वही defensive
ifकई files में दोहराया जा रहा है, तो यह संकेत है कि जिस जानकारी को validate करना चाहिए था वह type में समाई नहीं है
1 टिप्पणियां
Lobste.rs की राय
अगर JavaScript/TypeScript जिस code style को बढ़ावा देता है, उससे तकनीकी या ergonomics के स्तर पर टकराव है, तो JS में compile होने वाली कई भाषाओं में से कोई एक क्यों न इस्तेमाल करें
Haskell, Elm, F# का ज़िक्र हुआ, और PureScript, js_of_ocaml, Reason, LunarML जैसी कई भाषाएँ भी हैं जिन्हें लेखक और अधिक इस्तेमाल करना चाहता है। लेखक ने Why TypeScript Won’t Save You नाम का लेख भी लिखा है, जहाँ उसने अपनी पसंदीदा भाषाओं से और तुलना की है, और https://learnelm.dev भी चलाता है।
या फिर शायद तुलना करना ही मकसद है, ताकि दिखाया जा सके कि TypeScript कई मामलों में पर्याप्त नहीं है और लोग दूसरे toolchain या ideas अपनाने पर सोचें
ज़्यादातर लोगों के पास बस कोई दूसरी भाषा चुनने का विकल्प या समय ही नहीं होता
काम में मुझे branded type बहुत पसंद हैं, लेकिन यह बात सच में खटकती है कि branded numbers से ही index होने वाला Array या TypedArray बनाया नहीं जा सकता
TypedArray में branded numbers को store करना, या और सटीक कहें तो उन्हें निकालकर पढ़ना भी संभव नहीं है। भले इसके लिए IndexArray या IndexTypedArray जैसे अलग type set की ज़रूरत पड़े, फिर भी ऐसा feature ज़रूर होना चाहिए
लेकिन काफ़ी जटिल database schema में अगर हर ID पर branded type लगाया जाए, तो TypeScript बेकार join या condition पकड़ लेता है। function signatures भी ज़्यादा स्पष्ट हो जाते हैं और कई तरह की गलतियाँ करना कठिन हो जाता है
चाहें तो TypedArray के values के लिए भी यही तरीका अपनाया जा सकता है
TArray<Foo, MyEnum>जैसा लिखा जा सकता है। लेकिन यह C++ की बात हैZig की
stdlibrary मेंcomptimeसे बना EnumArray है। यह dense enum और sparse enum दोनों को indexing के लिए इस्तेमाल कर सकता है, compile time पर सही indexer निकाल सकता है, और इससे भी व्यापक features देता है।इस तरह की precision typing मुझे अब लगातार ज़्यादा पसंद आ रही है। यह codebase में logical bugs के घुसने को काफ़ी हद तक रोकती है