อย่าตรวจสอบ แต่จงพาร์ส — ในภาษาที่ไม่ค่อยเอื้ออย่าง TypeScript
(cekrem.github.io)- เมื่อมีการกระจายเช็กอย่าง
if (user.email)อยู่ทั่วโค้ด TypeScript ข้อเท็จจริงที่เคยตรวจแล้วจะไม่ถูกเก็บไว้ในชนิดข้อมูล ทำให้ส่วนลึกลงไปใน call stack ยังต้องคอยสงสัยเงื่อนไขเดิมซ้ำ ๆ - พาร์เซอร์รับอินพุตดิบแล้วคืนค่าเป็น ชนิดข้อมูลที่แคบลงกว่าเดิม หรือข้อมูลความล้มเหลว ทำให้ส่วนที่เหลือของโปรแกรมสามารถเชื่อถือข้อเท็จจริงที่ผ่านการตรวจแล้วได้ เช่น
EmailAddress - ใน TypeScript ที่ใช้ระบบชนิดข้อมูลแบบ structural typing นั้น
stringกับEmailจะไม่ถูกแยกออกจากกันโดยธรรมชาติ จึงต้องอาศัย branded type ที่อิงunique symbolและการยืนยันasแบบจำกัด เพื่อเลียนแบบขอบเขตแบบ nominal - discriminated union อย่าง
Parsed<T>ทำให้ความสำเร็จและความล้มเหลวปรากฏอยู่ใน type signature แต่เพราะไม่มีmatchexpression โดยเฉพาะ จึงต้องเขียน exhaustive check เองด้วยnever - Zod, io-ts, valibot สามารถสร้างทั้งพาร์เซอร์และชนิดข้อมูล TypeScript จากสคีมาเดียวกันได้ แต่กฎว่าต้อง พาร์สที่ทุก boundary ก่อนมองอินพุตภายนอกเป็นโดเมนไทป์ ก็ยังเป็นภาระของนักพัฒนาอยู่ดี
การตรวจสอบทิ้งข้อมูลทิ้งไป แต่การพาร์สเก็บมันไว้ในชนิดข้อมูล
- หลักการ Parse, don’t validate ของ Alexis King วางความต่างระหว่าง validator กับ parser ไว้เป็นแกนหลัก
- validator ตัดสินว่า “ค่านี้ใช้ได้” แล้วส่งต่อการไหลของโปรแกรมด้วย boolean หรือ exception
- parser รับอินพุตดิบแล้วสร้างชนิดข้อมูลที่แม่นยำกว่า หรือคืนเหตุผลของความล้มเหลว
- ถ้าปล่อยให้ชนิดข้อมูลกว้างอยู่เหมือน
User.email: string,User.age: numberต่อให้isValidUser(user): booleanผ่านแล้ว TypeScript ก็ยังจำข้อเท็จจริงนั้นไม่ได้ - หลังจากนั้น ในโค้ดอย่าง
emailService.send(user.email, ...)ค่าuser.emailก็ยังคงเป็น string ทั่วไป ที่อาจเป็นสตริงว่าง,"hello", หรือ"definitely not an email"ได้อยู่ดี - การไหลของโค้ดที่ต้องกลับมาเช็กเงื่อนไขเดิมหลายจุด จึงใกล้เคียงกับสิ่งที่ King เรียกว่า shotgun parsing
API ที่ให้ชนิดข้อมูลเป็นหลักฐานในตัว
- รูปแบบที่ต้องการคือ function signature อย่าง
sendWelcome(user: ValidUser)ที่รับได้เฉพาะค่าที่ผ่านการพาร์สแล้ว - ในโครงสร้างแบบนี้ ต้องผ่านพาร์เซอร์ก่อนเรียก
sendWelcomeเสมอ และภายในฟังก์ชันก็ไม่จำเป็นต้องตรวจซ้ำหรือใส่ifป้องกันเพิ่มเติม - ใน Elm เรื่องนี้จัดการได้ง่ายด้วย opaque type และ smart constructor แต่ใน TypeScript ต้องใช้อุปกรณ์มากกว่านั้นเพื่อให้ได้ผลลัพธ์แบบเดียวกัน
สร้างขอบเขตแบบ nominal ด้วย branded type
- TypeScript ใช้ ระบบชนิดข้อมูลแบบ structural typing ดังนั้นชนิดข้อมูลที่มี shape เดียวกันจะถูกมองว่าเป็นชนิดเดียวกัน
stringก็คือstringและไม่มีความสามารถสร้างชนิดใหม่ที่ต่างออกไปจริง ๆ แบบnewtypeของ Haskell
- ทางอ้อมที่ชุมชนใช้กันคือ branding หรือ tagging
- วิธีง่ายคือใช้ phantom field แบบ string literal เช่น
{ readonly __brand: "Email" } - วิธีที่แข็งแรงกว่าคือใช้
unique symbolที่ไม่ export ออกนอกโมดูลเป็นคีย์ของแบรนด์
- วิธีง่ายคือใช้ phantom field แบบ string literal เช่น
- ชนิดข้อมูลตัวอย่างจะมีรูปแบบอย่าง
type Email = string & { readonly [EmailBrand]: true },type Age = number & { readonly [AgeBrand]: true } - ฟิลด์แบรนด์เป็นตัวบ่งชี้ระดับชนิดข้อมูลที่ไม่มีอยู่จริงตอนรันไทม์ แต่ทำให้
Emailกับstringถูกปฏิบัติแตกต่างกันในขั้นคอมไพล์ - แบรนด์ทำงานได้ทางเดียวเท่านั้น
Emailสามารถกำหนดให้กับstringได้- แต่
stringทั่วไปไม่สามารถเข้าเป็นEmailได้โดยตรง
พาร์เซอร์อนุญาตให้ยืนยันชนิดข้อมูลได้เฉพาะที่ trust boundary
parseEmail(raw: string): Parsed<Email>จะคืนความล้มเหลวถ้าไม่มี@ในสตริง และถ้าผ่านก็จะสร้าง branded type ด้วยraw as Email- การยืนยัน
as Emailเป็นข้อยกเว้นที่ยอมรับได้ เพราะพาร์เซอร์คือ trust boundary- ถ้ามีการยืนยัน
stringเป็นEmailที่อื่นในโค้ดเบส การออกแบบนี้ก็จะพัง - สามารถแยกพาร์เซอร์ไว้อีกโมดูลหนึ่ง และถ้าเจอ brand assertion นอกนั้นก็ถือเป็นบั๊กได้
- ถ้ามีการยืนยัน
Parsed<T>ในตัวอย่างมีรูปแบบเป็น{ kind: "ok"; value: T } | { kind: "err"; error: ParseError }- ความล้มเหลวไม่ได้ซ่อนอยู่ใน exception แต่แสดงออกมาตรง ๆ ใน type signature
- เมื่อใช้ตัวแยกแบบสตริงอย่าง
kind: "ok" | "err"การทำ type narrowing จะทำงานอย่างซื่อตรงกว่าเมื่อมีการเพิ่ม variant ใหม่ในภายหลัง
- ตัวอย่าง
parseEmailตั้งใจให้บางเพื่อให้เห็นแนวคิด โดยพาร์เซอร์อีเมลจริงควรจัดการเรื่อง trim, lowercase, การตรวจโดเมน และอื่น ๆ เพิ่มเติม
แยกอินพุตดิบออกจากโดเมนไทป์ที่เชื่อถือได้
- การแยก
UnvalidatedUserกับValidUserออกจากกัน ทำให้แบ่งชัดเจนระหว่างค่าที่มาจากเครือข่ายหรืออินพุตภายนอก กับค่าที่เชื่อถือได้ภายในโดเมนUnvalidatedUserกำหนดid,email,ageเป็นunknownValidUserใช้ branded type อย่างUserId,Email,Age
- ถ้าทำแบรนด์ให้
UserIdด้วย ก็จะช่วยกันความผิดพลาดจากการส่ง ID คนละประเภท เช่นOrderIdไปในจุดที่ต้องการUserId parseUser(raw: unknown): Parsed<ValidUser>จะค่อย ๆ ทำให้อินพุตดิบแคบลงทีละขั้น- ตรวจว่าอินพุตเป็นอ็อบเจ็กต์หรือไม่
- ตรวจว่ามีฟิลด์
id,email,ageหรือไม่ - ตรวจว่า
emailเป็นสตริงหรือไม่ - เรียก
parseUserId,parseEmail,parseAgeแยกกัน และคืนค่าทันทีเมื่อเกิดความล้มเหลว - ถ้าสำเร็จทั้งหมดก็คืน
ValidUser
- วิธีนี้อาจยืดยาวกว่าใน F# หรือ Elm แต่ก็ทำให้
sendWelcome(user: ValidUser)ปลอดภัยได้จริง
จุดที่ TypeScript ชวนให้หงุดหงิด
- จุดเสียดทานแรกคือการยืนยัน
as Emailภายในพาร์เซอร์- ในภาษาที่มี nominal type จริง ๆ smart constructor สามารถคืนชนิดใหม่ได้โดยไม่ต้อง “โกหก”
- แต่แบรนด์ของ TypeScript เป็นเพียงตัวมาร์กระดับชนิดข้อมูลแบบเสมือน จึงต้องข้ามด้วย assertion ในพาร์เซอร์
- จุดเสียดทานที่สองคือ exhaustive check
- discriminated union ของ TypeScript ทรงพลังมากกับสไตล์นี้ แต่ไม่มี
matchexpression โดยเฉพาะ - จึงต้องเขียนแพตเทิร์นอย่าง
const _exhaustive: never = resultเองในdefaultของswitch - ถ้ามีการเพิ่ม variant ที่สามให้
Parsedการกำหนดให้neverจะล้มเหลว และคอมไพเลอร์จะบอกตำแหน่งให้
- discriminated union ของ TypeScript ทรงพลังมากกับสไตล์นี้ แต่ไม่มี
satisfiesใช้เป็น escape hatch ที่สุภาพกว่าการ cast ได้const x = { ... } satisfies Configจะตรวจชนิดข้อมูลให้ ขณะเดียวกันก็ไม่ขยาย literal type โดยไม่จำเป็น
JSON.parseคืนค่าเป็นanyจึงปลอดภัยกว่าถ้ากำกับชนิดทันทีเป็นunknown- รับค่าในรูป
const raw: unknown = JSON.parse(input)แล้วให้พาร์เซอร์เป็นผู้ตัดสินต่อว่ามันเป็นโดเมนไทป์หรือไม่ JSON.parseไม่ใช่ validator แต่เป็นขั้น deserialization ที่แปลงไบต์ให้เป็นค่า JavaScript
- รับค่าในรูป
ไลบรารีอย่าง Zod ช่วยลดงานซ้ำ
- Zod, io-ts, valibot มอบแพตเทิร์นเดียวกันในรูปแบบที่สะดวกกว่าการเขียนพาร์เซอร์เอง
- ตัวอย่างของ Zod สร้างทั้งพาร์เซอร์และชนิดข้อมูล TypeScript จากสคีมาเดียว
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>เพื่อดึงชนิดข้อมูลออกมา ValidUserSchema.safeParse(rawInput)จะคืนdataเมื่อสำเร็จ และerrorเมื่อไม่สำเร็จ
.brand()ของ Zod ก็เป็นความสามารถ ระดับชนิดข้อมูล เช่นกัน เหมือน symbol brand ที่ทำเอง และไม่มีพฤติกรรมตอนรันไทม์- ไลบรารีช่วยผูกพาร์เซอร์กับชนิดข้อมูลไว้ในนิยามเดียว ทำให้รักษา boundary ได้ง่ายขึ้น แต่ก็ไม่ได้บังคับกฎแทนนักพัฒนาว่าต้องใช้มันกับทุก boundary ภายนอก
Userที่มาจากเครือข่ายยังไม่ใช่โดเมนUserจนกว่าจะผ่านการพาร์ส และควรหลีกเลี่ยงสิ่งล่อใจที่จะใช้ type assertion เพื่อเลี่ยงข้อความผิดพลาด
ให้ชนิดข้อมูลเก็บหลักฐานไว้ แทนที่จะพึ่งความจำ
- หลักการเล็ก ๆ นี้คือ “ให้ type system เป็นผู้ถือหลักฐานไว้ และอย่าฝากมันไว้กับความจำของมนุษย์”
- ถ้าตรวจเงื่อนไขบางอย่างแล้วไม่ได้เข้ารหัสผลลัพธ์นั้นลงในชนิดข้อมูล โค้ดในภายหลังก็จะเผลอสมมุติได้ง่ายว่าการตรวจนั้นเสร็จไปแล้ว
- ใน TypeScript หลักการนี้มักถูกทำให้เกิดขึ้นด้วยเครื่องมือ 3 อย่าง
- branded type ที่เลียนแบบอัตลักษณ์แบบ nominal
- discriminated union ที่ทำให้ความสำเร็จและความล้มเหลวมองเห็นได้
- ขอบเขตที่เข้มงวดระหว่าง
unknownของอินพุตภายนอกกับโดเมนไทป์ที่เชื่อถือได้
- ไม่ใช่ว่าทุกโค้ดควรถูกเปลี่ยนเป็น parsing pipeline เสมอไป แต่ถ้า
ifเชิงป้องกันแบบเดิมซ้ำ ๆ ปรากฏอยู่ในหลายไฟล์ นั่นคือสัญญาณว่าข้อมูลที่ควรถูกตรวจยังไม่ได้ถูกบรรจุไว้ในชนิดข้อมูล
1 ความคิดเห็น
ความคิดเห็นบน Lobste.rs
ถ้าสไตล์โค้ดที่ JavaScript/TypeScript ต้องการขัดแย้งกับสิ่งที่อยากได้ทั้งในเชิงเทคนิคและความถนัดในการใช้งาน ก็น่าจะใช้หนึ่งในบรรดา ภาษาที่คอมไพล์เป็น JS จำนวนมากแทนได้ไม่ใช่หรือ
มีการพูดถึง Haskell, Elm, F# และยังมีภาษาสายที่ผู้เขียนน่าจะอยากใช้มากกว่าอีกมาก เช่น PureScript, js_of_ocaml, Reason, LunarML เป็นต้น ผู้เขียนถึงขั้นเขียนบทความชื่อ Why TypeScript Won’t Save You และเปรียบเทียบกับภาษาที่ตนชอบเพิ่มเติม อีกทั้งยังดูแล https://learnelm.dev ด้วย
หรือบางทีเป้าหมายอาจเป็นการเปรียบเทียบเอง เพื่อแสดงให้เห็นว่า TypeScript ในหลายกรณียังไม่เพียงพอ และชวนให้หันไปใช้ toolchain หรือแนวคิดอื่น ๆ
คนส่วนใหญ่ ไม่มีทางเลือกหรือเวลาให้เลือกภาษาอื่นเฉย ๆ
ในงาน ผมชอบ branded type มาก แต่สิ่งที่กวนใจจริง ๆ คือไม่สามารถสร้าง Array หรือ TypedArray ที่ index ได้ด้วย branded number เท่านั้น
TypedArray ไม่สามารถเก็บ branded number ได้ หรือพูดให้แม่นกว่านั้นคือแม้แต่อ่านค่าออกมาก็ยังไม่ได้ ต่อให้ต้องมีชุด type แยกอย่าง IndexArray หรือ IndexTypedArray ก็อยากให้มีฟีเจอร์แบบนี้จริง ๆ
ถ้าใช้ branded type กับ ID ทุกตัวใน schema ฐานข้อมูลที่ค่อนข้างซับซ้อน TypeScript จะช่วยจับ ตอนสร้าง join หรือเงื่อนไขที่ไม่สมเหตุสมผลได้ signature ของฟังก์ชันก็ชัดขึ้น และทำให้สร้างความผิดพลาดหลายอย่างได้ยากขึ้น
ถ้าต้องการ ค่าของ TypedArray ก็ทำแบบเดียวกันได้
TArray<Foo, MyEnum>แต่เรื่องนี้เป็นของ C++ไลบรารี
stdของ Zig มี EnumArray ที่ implement ด้วยcomptimeใช้ enum แบบหนาแน่นหรือแบบ sparse เป็น index ได้ และยังมีความสามารถกว้างขึ้น เช่น คำนวณ indexer ที่ถูกต้องตอน compile-timeผมเริ่มชอบ การระบุ type อย่างละเอียดแม่นยำ แบบนี้มากขึ้นเรื่อย ๆ มันช่วยกันไม่ให้ logical bug หลุดเข้ามาใน codebase ได้มาก