TypeScript Needs to Emit Runtime Type Information - ข้อเรียกร้องให้ TypeScript ปล่อยข้อมูลชนิดตอนรันไทม์
(github.com/akutruff)- รวบรวมรายการโปรเจกต์ Type Mapping, Code Generation / External Tool และ Adapter ที่เสนอความจำเป็นให้ TypeScript ปล่อย ข้อมูลชนิดตอนรันไทม์ และใช้เป็นทางเลี่ยงปัญหานี้
- ปัญหาหลักคือเมื่อจัดการ serialization และ validation โดยไม่มีระบบชนิดแบบ reflective จะต้องมี boilerplate ไม่รู้จบ หรือการสร้างโค้ด bespoke โดยอิงไฟล์ schema
- ทางเลี่ยงที่ยกมามี io-ts, zod เป็นต้น แต่มีความไม่สะดวกตรงที่ต้องประกาศชนิดซ้ำในรูปแบบเฉพาะของแต่ละไลบรารี และไลบรารีเหล่านี้ไม่สามารถรองรับความสามารถด้านชนิดทั้งหมดของ TypeScript ได้
- แม้ยอมรับว่าการลบชนิดของ TypeScript มีข้อดีคือทำให้โปรเจกต์ JavaScript สามารถใช้ JavaScript ที่ถูกปล่อยออกมาได้โดยไม่ต้องมีความรู้เกี่ยวกับ TypeScript แต่ก็โต้แย้งว่าสามารถปล่อยข้อมูลชนิดในรูปแบบตาราง lookup ที่แยกจากโค้ดได้
- ขอร้องว่าอย่าแก้ด้วย decorator พร้อมเสนอแนวทางอย่าง higher-order function ที่คอมไพเลอร์รู้จัก เช่น
typescript.generateRuntimeType<T>()เพื่อรองรับการใช้ interface และชนิดจากไลบรารีภายนอก รวมถึงแนวทางแบบ F# Type Providers และ C# Source Generators - เชื่อมโยงไปยังการพูดคุยเดิมที่เกี่ยวข้องอย่าง GitHub issue อายุ 8 ปี และขอให้โปรเจกต์ที่เจอปัญหาเดียวกันส่ง PR เพื่อเพิ่มเข้ารายการ โดยไม่ขึ้นกับจำนวนดาว
1 ความคิดเห็น
ความเห็นจาก Hacker News
ต้องอ่านคำอธิบายปัญหาราว 4 รอบกว่าจะเข้าใจว่าต้องการอะไร และกรณีนี้ก็แสดงให้เห็นชัดว่าทำไม การเขียนให้กระชับ จึงสำคัญ
สิ่งที่ต้องการจริง ๆ ดูเหมือนจะเป็นความปลอดภัยของชนิดข้อมูลตอนรันไทม์ แต่ TypeScript วางเส้นมาตลอดว่าจะไม่กลายเป็นรันไทม์ทดแทน/ส่วนขยายของ JavaScript จึงดูมีโอกาสน้อย TypeScript มีหน้าที่คอมไพล์เป็น JS แล้วก็จบ ส่วนสิ่งที่เกิดขึ้นต่อจากนั้นใน V8 เป็นต้นไปอยู่นอกขอบเขต
ข้อเรียกร้องว่า “TypeScript ควรปล่อยข้อมูลชนิดข้อมูลสำหรับรันไทม์ออกมา” จึงใกล้เคียงกับการขอ ผลิตภัณฑ์ใหม่ ที่ค่อนข้างต่างจาก TypeScript ปัจจุบัน
เช่น อยากมีฟังก์ชัน
validateแบบทั่วไปที่รับอินเทอร์เฟซตามอำเภอใจและอ็อบเจ็กต์มาเพื่อตรวจสอบได้ อาจทำได้โดยใช้การสะท้อนตอนคอมไพล์เพื่อสร้างโค้ดตรวจสอบ JS ตามชนิดข้อมูล หรือส่งTเป็นอาร์กิวเมนต์ตอนรันไทม์เพื่อนำมาเปรียบเทียบ แต่ภายใต้ปรัชญาปัจจุบันของ TypeScript ก็ดูยากTypeScript รู้ข้อมูลชนิดข้อมูลจำนวนมากระหว่างคอมไพล์ แต่เมื่อคอมไพล์เสร็จก็ทิ้งข้อมูลนั้นไป ทั้งที่สามารถส่งออกข้อมูลนี้เป็นไฟล์หรือเก็บไว้เป็นเมตะดาต้าใน
Reflectได้ การทิ้งมันไปจึงเป็นข้อจำกัดที่น่าเสียดาย โดยเฉพาะเมื่อคิดถึงธรรมชาติของ JavaScript ที่ว่า “ทุกอย่างคืออ็อบเจ็กต์”ถึงจะยังไม่ได้ความปลอดภัยของชนิดข้อมูลแบบ TS ที่แม่นยำครบถ้วน ก็ยังสามารถเสริมการตรวจสอบบางส่วนด้วย getter/setter หรือ ES2015
Proxyได้ และถ้าเก็บข้อมูลชนิดข้อมูลไว้ตอนรันไทม์ ก็จะเปิดทางให้ความเป็นไปได้ที่น่าสนใจหลายอย่างในฐานะ เมตะดาต้าที่รันได้ตอนนี้ TS
enumก็ถูกส่งออกเป็นอ็อบเจ็กต์ JS อยู่แล้ว จึงตรวจดูได้ตอนรันไทม์ แต่ยูเนียนชนิดข้อมูลแบบสตริงลิเทอรัลไม่เป็นเช่นนั้น นอกจากนี้ TypeScript ยังรองรับ ฟังก์ชัน type guard ดังนั้นการใช้ข้อมูลในระบบชนิดข้อมูลมา生成ฟังก์ชันแบบนี้อัตโนมัติก็ดูไม่ใช่เรื่องยากมากนึกเทียบกับไฟล์ PDB ก็ได้ TypeScript มีข้อมูลนี้อยู่แล้วก่อนจะทิ้งไป จึงไม่ถึงกับต้องมีผลิตภัณฑ์ใหม่ทั้งหมด
https://github.com/microsoft/TypeScript/issues/3628
จากมุมมองของ PM ของ TypeScript ก็เข้าใจความต้องการนี้ เพราะการตรวจสอบข้อมูลมักต้องใช้ การตรวจชนิดข้อมูลตอนรันไทม์ และก็มีไลบรารีจำนวนมากที่พยายามมาอุดช่องว่างนี้
แต่การที่มีไลบรารีจำนวนมากซึ่งตัดสินใจออกแบบต่างกันเอง ก็เป็นสัญญาณว่าปัญหานี้ไม่ใช่ปัญหาที่มีคำตอบชัดเจนเพียงหนึ่งเดียวและถูกแก้ไปแล้ว TypeScript รู้เรื่องนี้ตั้งแต่ช่วงออกแบบแรกเริ่ม และผมคิดว่าหลักการนี้ก็ยืนหยัดมาได้ดี
ในทางกลับกัน TypeScript ก็ทรงพลังพอที่จะอธิบายด้วยชนิดข้อมูลได้อย่างแม่นยำว่าสิ่งที่ไลบรารีตรวจชนิดข้อมูลตอนรันไทม์ทำจริง ๆ คืออะไร และผู้ใช้ก็สามารถประกอบตรรกะการตรวจสอบตอนรันไทม์จากชนิดข้อมูลผ่าน API ได้ ระดับนี้ให้ความยืดหยุ่นที่สมเหตุสมผลแล้ว
เช่น นิยามตัวเลขในช่วงที่กำหนด หรือสตริงที่ตรงกับรูปแบบรหัสไปรษณีย์เป็นชนิดข้อมูล แล้วให้คอมไพเลอร์ใช้ฟังก์ชันตรวจสอบที่เขียนเหมือนฟังก์ชันทั่วไปเพื่อตรวจความถูกต้อง จึงนึกภาพถึงภาษาที่ทำแบบนั้นได้ เลยสงสัยว่าการไม่มีฟีเจอร์นี้เป็นการตัดสินใจเชิงออกแบบเพื่อหลีกเลี่ยงความซับซ้อนที่ไม่จำเป็น หรือมีข้อจำกัดทางเทคนิคอย่างเรื่องประสิทธิภาพ
ตอนนี้คนที่ต้องการก็สามารถทำพรีโปรเซสเซอร์ที่สร้างอ็อบเจ็กต์รันไทม์จากข้อมูลชนิดข้อมูลก่อนส่งให้
tscได้อยู่แล้ว แต่ความพยายามเหล่านี้กระจัดกระจายกันไป ถ้ามีพรีโปรเซสเซอร์แบบปลั๊กอินอย่างเป็นทางการและมี ecosystem รองรับ ก็อาจค้นพบทางออกที่ดีได้แค่จุดประกายขึ้นมา ชุมชนก็น่าจะช่วยกันดูแลรักษาต่อ และสามารถตอบโจทย์ความต้องการด้านการสร้างโค้ดได้หลายแบบ ตั้งแต่การสร้างไคลเอนต์ไปจนถึงการยืนยันชนิดข้อมูลตอนรันไทม์
Classหลังคอมไพล์การไม่ทำแบบนี้มีเหตุผลอยู่ ถ้า TypeScript ทำเช่นนั้น มันก็จะกลายเป็นเหมือน runtime ที่คร่อมอยู่บน JavaScript และกลายเป็นภาษาใหม่ที่คอมไพล์ไปเป็น JS
ตอนนี้ TypeScript ยังใกล้เคียงกับ JavaScript ที่มี type annotation อยู่แล้ว ภาษาที่คอมไพล์ไปเป็น JS ก็มีเยอะมาก ดังนั้นก็ไปใช้ตัวใดตัวหนึ่งได้ คนที่ต้องการ TypeScript ที่มี runtime type ดูเหมือนจะอยากเขียนโค้ดสไตล์ Java/OOP มากกว่า JavaScript แต่ JavaScript เป็นภาษา dynamic type และนั่นก็เป็นข้อดีอย่างหนึ่งด้วย
generateTypeInfo!()ที่ขยายออกเป็นอ็อบเจ็กต์ JS ซึ่งเข้ารหัส typeFooก็ยังคอมไพล์เป็น JavaScript ที่อ่านรู้เรื่องได้อยู่เพียงแต่ macro นี้จะทำลายคุณสมบัติที่ว่า “TS = JS ที่มี type annotation และการคอมไพล์คือแค่ลบ annotation ออก” เพราะต้องคำนวณ structural type ของ
Fooจริง ๆอีกทั้ง type ของ TypeScript เป็นแบบ structural ดังนั้นถ้าตรวจโครงสร้างอ็อบเจ็กต์ที่ runtime ก็พอจะรู้ type ได้ระดับหนึ่ง แต่ถ้าต้องการข้อมูลเชิงนามธรรมที่ถูกลบหายไป เช่น การเป็นสมาชิกของ string union หรือชื่อ type ก็จะกลายเป็นปัญหาที่ยากอย่างรวดเร็ว เพราะ type system ของ TypeScript เป็นแบบทัวริงสมบูรณ์และมีการแปลงโครงสร้างโดยนัย
เอาจริง ๆ ต่อให้ไม่มี runtime ถ้าเปิดเผย type ออกมาในรูปข้อมูลก็ทำ reflection ได้อยู่แล้ว พอเห็นนักพัฒนาจำนวนมากพยายามเลียนแบบสิ่งนี้ ก็เลยรู้สึกว่าการไม่ทำเสียอีกที่ดูไม่ฉลาด
runtime type เป็นการแก้ปัญหาที่ต้องดูแล type system สำหรับคอมไพล์และ type system สำหรับตรวจสอบข้อมูลแยกกันแบบซ้ำซ้อน ซึ่งดูไม่เกี่ยวกับ OOP โดยตรงนัก
io-tsที่นิยมใช้เป็นทางอ้อมแก้ปัญหานี้ก็เอนไปทาง functional programming อย่างมากNaNอยู่เรื่อย ๆจาก workflow ที่ผมเห็น TypeScript เป็นภาษาที่คอมไพล์ไปเป็น JS อยู่แล้ว ดังนั้นในเมื่อเป็นแบบนั้น ก็ควรใช้ข้อได้เปรียบนั้นให้เต็มที่
มันใกล้กับ type annotation + Babel มากกว่า และการเพิ่มฟีเจอร์ที่มีประโยชน์มากอีกอย่างก็ดูเป็นความคิดที่ดี การที่ไม่สามารถแปลงสตริงที่
JSON.parseแล้วให้เป็นโครงสร้างที่มี type อย่างปลอดภัยได้เป็นเรื่องแปลก และภาษาอื่น ๆ ส่วนใหญ่ทำได้ก่อนจะพูดถึงข้างล่าง นี่เป็นประเด็นที่ถกกันได้อย่างสมเหตุสมผล และผมไม่คิดว่ามีคำตอบที่ถูกต้องเพียงหนึ่งเดียวอย่างเป็นกลาง
TypeScript เป็นชั้นทางเลือกบน JavaScript ยกเว้นกรณี
Enumที่ปล่อยอ็อบเจ็กต์ออกมาด้วย และโค้ด TS ก็กลายเป็น JS ได้ด้วยการลบ type ออกเท่านั้น ถ้าไม่ออกนอกหลักการนี้มากเกินไป สิ่งที่ต้องการจริง ๆ ก็ดูคล้ายไลบรารีที่สร้าง serializer/validator จาก type มากกว่า ไลบรารีแบบนั้นมีอยู่แล้วจำนวนมาก และสุดท้ายก็ดูเหมือนเป็นการเรียกร้องให้เลือกหนึ่งในนั้นมาเป็นตัวเลือกมาตรฐานอย่างเป็นทางการโดยส่วนตัวผมไม่อยากให้ภาษาแกนของ TypeScript มี runtime reflection เข้ามา ที่ runtime ควรมีแค่ JavaScript ล้วน ๆ และผมให้คุณค่ากับการที่ JS ที่ปล่อยออกมายังอ่านง่ายและดีบักได้ แม้ไม่มี source map
Enumแล้วก็ยังมีข้อยกเว้นอื่นที่ปล่อยโค้ด runtime ออกมาเช่นกัน และส่วนใหญ่ก็มักถูกมองว่าเป็นความผิดพลาดเหมือนกัน ตัวอย่างเด่นคือmoduleและnamespaceที่เคยเป็นโครงสร้าง runtimeอย่างไรก็ตาม ช่วงหลายปีที่ผ่านมา ส่วนใหญ่ถูกใช้ภายใน TypeScript เอง และ TypeScript ก็เริ่มถอยออกจากตรงนั้นในช่วงหลัง อีกข้อยกเว้นที่ค่อนข้างได้รับความนิยมคือ parameter properties ซึ่งเป็นไวยากรณ์สำหรับกำหนด type ของสมาชิกคลาสจากพารามิเตอร์ของ constructor และดูเหมือนจะหลบเสียงคัดค้านได้มากกว่าเพราะช่วยลด boilerplate ที่ซ้ำซ้อน
แทนที่จะไปอ้อนวอนเทพ TypeScript อาจจะดีกว่าถ้าไปทำข้อตกลงกับฝั่ง JavaScript เพื่อเอา type checking เข้าไปใน JS
ถ้าต้องการ runtime type ก็ใช้ type guard และถ้าต้องใช้บ่อยก็ใช้
io-tsหรือzodเพื่อเขียน type ให้เป็น validator/codec/schema จนกว่าจะมีแนวทางที่ JavaScript เห็นพ้องร่วมกัน ผมไม่คิดว่า TS spec, ตัว type checker และชุมชนควรต้องแบกรับเรื่อง runtime validationzodแล้ว derive type จากตรงนั้น ชอบตรงที่ validation พัฒนาไปพร้อมกับการเปลี่ยนแปลงของ typeบางคนอาจรู้สึกว่าฟีเจอร์แบบนี้ควรอยู่ในภาษา แต่แต่ละไลบรารีก็มีตัวเลือกด้านการออกแบบที่ชวนถกเถียงเยอะ ดังนั้นการเลือก implementation ให้ตรงกับความต้องการของโปรเจ็กต์อาจจะดีกว่า
อย่างไรก็ตาม แพตเทิร์นอย่าง runtime validation ของ
zodและการ derive type จาก schema ไม่ได้เข้ากับ pattern matching ของts-patternได้อย่างราบรื่นเสมอไป type definition ของไลบรารีพวกนี้ซับซ้อนราวกับเขาวงกต และบางครั้งโค้ดที่ดูเหมือนควรใช้ได้ก็ไม่ทำงานอย่างที่คาด ถ้าเอา runtime safety กับ exhaustiveness-checked pattern matching มารวมกันได้อย่างลื่นไหลก็น่าจะดีมากผมรู้สึกว่าทิศทางการออกแบบของ
Promise, decorator และ pipe operator แบบใหม่ ล้วนเดินมาผิดทางจำได้ว่าหนึ่งในนักพัฒนา TypeScript เคยบอกว่า ถ้าเริ่มใหม่ได้อีกครั้งคงจะไม่ใส่
enumเข้ามา เพราะenumเป็นฟีเจอร์เดียวที่ปล่อยโค้ด runtime ออกมาTypeScript ไม่เปลี่ยนพฤติกรรม runtime และก็ไม่มีอะไรเฉพาะของ TypeScript อย่าง
{#if}ด้วยอย่างไรก็ตาม TypeScript ก็มีฟีเจอร์อยู่หลายอย่างที่ไปไกลกว่า “JavaScript + type annotation” เช่น
namespace, ไวยากรณ์กำหนด property ของคลาสจากอาร์กิวเมนต์ constructor, experimental decorator แบบเก่า และไวยากรณ์พารามิเตอร์thisที่หายไปตอนคอมไพล์ แต่ก็ดูเหมือนมากกว่าแค่การใส่ type annotation ให้ฟังก์ชัน JSชื่อที่ดีกว่าน่าจะเป็น “TypeScript โปรดให้ reflection/runtime type มาเถอะ” มากกว่า
ตอนนี้วิธีแก้ที่ดีที่สุดน่าจะเป็น
emitDecoratorMetadata: https://www.typescriptlang.org/tsconfig#emitDecoratorMetadataผู้เขียนดูเหมือนจะเข้าใจเป้าหมายการออกแบบของ TypeScript ผิดไป เป้าหมายไม่ใช่การสร้างผลลัพธ์ JS ที่สะอาดโดยไม่มีความซับซ้อน แต่คือการทำให้ ความหมายเชิงรันไทม์ ของ TypeScript คงเหมือนกับ JavaScript
ยกเว้นกรณี
enumซึ่งเป็นข้อยกเว้นที่น่าเสียดาย TypeScript จะกลายเป็น JavaScript ได้เพียงแค่ลบ type annotation ออก ระบบนิเวศรอบ ๆ TypeScript พึ่งพาการลบ type ออกทั้งหมดอยู่แล้ว และถ้าความต้องการนี้ถูกรับไปใช้ การรองรับ TS ของ ESBuild, Deno และ Bun อาจแทบเป็นไปไม่ได้ เพราะแต่ละตัวจะต้องนำtscทั้งก้อนมาเขียนใหม่ด้วยภาษาของตัวเองในทางกลับกัน ไลบรารีที่ซับซ้อนซึ่ง OP บ่นถึงนั้นถูกทำไว้ใน user space จึงเข้ากันได้กับเครื่องมือเหล่านี้
keyofเป็นรายการคีย์ของคลาส หรือทำให้คลาส TS กำหนดค่าเริ่มต้นของคีย์เป็นundefinedแบบเดียวกับคลาส ES6ตอนนี้คลาส TypeScript จะลบคีย์ทั้งหมดที่ไม่ได้กำหนดไว้อย่างชัดเจนออก ทำให้เมื่อเรียก
Object.keys()กับอินสแตนซ์ใหม่จะไม่เห็นอะไรเลย แค่มีตัวเลือกในtsconfig.jsonให้แปลงคลาส TS ให้เหมือนคลาส ES6 หรืออนุญาตให้ใช้คลาส ES6 ควบคู่ใน TS ได้ ก็จะทำให้การสร้างโค้ดง่ายขึ้นมากแล้วนอกจากนี้ ถ้ามีไวยากรณ์แบบสแตติกอย่าง
tstypeof Foo::barที่คอมไพล์เป็น"string"ได้ก็คงยอดเยี่ยม แค่มี RTTI พื้นฐานก็อาจทำให้โค้ด TypeScript แบบ boilerplate ที่ยุ่งเหยิงหายไปได้ทันทีจำนวนมากดีใจที่ได้เห็นบทความนี้ เริ่มใช้ TypeScript ในปี 2018 และหลังจากนั้นราว 2 ปี ก็เริ่มเชื่อว่านี่ไม่ใช่คำตอบที่สมบูรณ์โดยเนื้อแท้
ผมมาจาก Haskell/C#/F# และแม้ TS จะมีระบบชนิดที่ทรงพลัง แต่ยกเว้นบางช่วงตอนพัฒนาแล้ว มันก็ไม่ได้มอบข้อดีแบบที่ภาษาเหล่านั้นให้มากนัก เมื่อต้องจัดการกับโลกความเป็นจริง มันมีข้อจำกัดในทางปฏิบัติมากกว่า C# อย่างชัดเจน ถ้าไม่ตระหนักว่าหลังคอมไพล์แล้วมันกลายเป็น JS ที่ไม่มีการตรวจสอบต่อ คุณก็ต้องคอยระวัง abstraction ที่รั่ว อยู่เสมอเพื่อหลีกเลี่ยงบั๊กที่ไม่ควรเกิดขึ้นได้ในเครื่องมือที่อ้างว่ามี static type
ถ้าต้องพัฒนา .NET ก็ใช้ C# ถ้าต้องพัฒนาเว็บก็ใช้ TypeScript ประมาณนั้น C# เป็น nominal type และมี reified generics ที่ค่อนข้างดี ส่วน TypeScript เป็น structural type และยอมแลกความ soundness เพื่อให้มีระบบชนิดที่ทรงพลังสำหรับแสดงความสัมพันธ์ของชนิดที่ซับซ้อน
ไม่แน่ใจว่าภาษาที่รวมสองอย่างนี้เข้าด้วยกันจะดีหรือไม่ และแม้ทั้งสองภาษาจะทำงานได้ดีในทิศทางของตัวเอง แต่ทิศทางเหล่านั้นก็ไม่ได้เข้ากันนัก
สำหรับการตรวจสอบข้อมูลตอนรันไทม์ ผมพอใจกับการใช้ https://zod.dev อย่างมาก
มันค่อนข้างเจ๋งที่สามารถแสดงเจตนาได้ตรงนั้นเลยด้วย API ที่ลื่นไหล โดยไม่ต้องนิยาม nominal type แยกต่างหาก
ตัวอย่างง่าย ๆ คือจำเป็นสำหรับการแยกเลข
2ออกจากจำนวนเงินสกุลเงิน2ในโดเมน