1 คะแนน โดย GN⁺ 4 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • เมื่อมีการกระจายเช็กอย่าง if (user.email) อยู่ทั่วโค้ด TypeScript ข้อเท็จจริงที่เคยตรวจแล้วจะไม่ถูกเก็บไว้ในชนิดข้อมูล ทำให้ส่วนลึกลงไปใน call stack ยังต้องคอยสงสัยเงื่อนไขเดิมซ้ำ ๆ
  • พาร์เซอร์รับอินพุตดิบแล้วคืนค่าเป็น ชนิดข้อมูลที่แคบลงกว่าเดิม หรือข้อมูลความล้มเหลว ทำให้ส่วนที่เหลือของโปรแกรมสามารถเชื่อถือข้อเท็จจริงที่ผ่านการตรวจแล้วได้ เช่น EmailAddress
  • ใน TypeScript ที่ใช้ระบบชนิดข้อมูลแบบ structural typing นั้น string กับ Email จะไม่ถูกแยกออกจากกันโดยธรรมชาติ จึงต้องอาศัย branded type ที่อิง unique symbol และการยืนยัน as แบบจำกัด เพื่อเลียนแบบขอบเขตแบบ nominal
  • discriminated union อย่าง Parsed<T> ทำให้ความสำเร็จและความล้มเหลวปรากฏอยู่ใน type signature แต่เพราะไม่มี match expression โดยเฉพาะ จึงต้องเขียน 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 ออกนอกโมดูลเป็นคีย์ของแบรนด์
  • ชนิดข้อมูลตัวอย่างจะมีรูปแบบอย่าง 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 เป็น unknown
    • ValidUser ใช้ 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 ทรงพลังมากกับสไตล์นี้ แต่ไม่มี match expression โดยเฉพาะ
    • จึงต้องเขียนแพตเทิร์นอย่าง const _exhaustive: never = result เองใน default ของ switch
    • ถ้ามีการเพิ่ม variant ที่สามให้ Parsed การกำหนดให้ never จะล้มเหลว และคอมไพเลอร์จะบอกตำแหน่งให้
  • 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 ความคิดเห็น

 
GN⁺ 4 시간 전
ความคิดเห็นบน 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 หรือแนวคิดอื่น ๆ

    • มีข้อจำกัดอย่าง codebase เดิม ความเชี่ยวชาญเฉพาะภาษาของทีม หรือนโยบายบริษัท รวมถึงการสนับสนุน เครื่องมือ และขนาดชุมชนที่น้อยกว่า
      คนส่วนใหญ่ ไม่มีทางเลือกหรือเวลาให้เลือกภาษาอื่นเฉย ๆ
    • โดยปกติน่าจะเป็นเพราะมี codebase TypeScript ขนาดใหญ่ หรือใช้ไลบรารี TypeScript ที่ไม่มีในภาษาอื่น
  • ในงาน ผมชอบ branded type มาก แต่สิ่งที่กวนใจจริง ๆ คือไม่สามารถสร้าง Array หรือ TypedArray ที่ index ได้ด้วย branded number เท่านั้น
    TypedArray ไม่สามารถเก็บ branded number ได้ หรือพูดให้แม่นกว่านั้นคือแม้แต่อ่านค่าออกมาก็ยังไม่ได้ ต่อให้ต้องมีชุด type แยกอย่าง IndexArray หรือ IndexTypedArray ก็อยากให้มีฟีเจอร์แบบนี้จริง ๆ

    • ผมก็ชอบ branded type เหมือนกัน แต่พอคุยกับคนอื่น ทุกคนมองว่ามันไม่คุ้มกับความพยายามที่ต้องลงไป
      ถ้าใช้ branded type กับ ID ทุกตัวใน schema ฐานข้อมูลที่ค่อนข้างซับซ้อน TypeScript จะช่วยจับ ตอนสร้าง join หรือเงื่อนไขที่ไม่สมเหตุสมผลได้ signature ของฟังก์ชันก็ชัดขึ้น และทำให้สร้างความผิดพลาดหลายอย่างได้ยากขึ้น
    • ถ้ายอม “โกหก” แรงพอ ก็สร้าง Array ที่ index ได้ด้วย branded number เท่านั้นได้
      ถ้าต้องการ ค่าของ TypedArray ก็ทำแบบเดียวกันได้
    • ที่ทำงาน เราใช้ “smart enum” กับ type ของ array แบบกำหนดเอง จึงเขียนได้ประมาณ TArray<Foo, MyEnum> แต่เรื่องนี้เป็นของ C++
      ไลบรารี std ของ Zig มี EnumArray ที่ implement ด้วย comptime ใช้ enum แบบหนาแน่นหรือแบบ sparse เป็น index ได้ และยังมีความสามารถกว้างขึ้น เช่น คำนวณ indexer ที่ถูกต้องตอน compile-time
      ผมเริ่มชอบ การระบุ type อย่างละเอียดแม่นยำ แบบนี้มากขึ้นเรื่อย ๆ มันช่วยกันไม่ให้ logical bug หลุดเข้ามาใน codebase ได้มาก