คุณเขียน Type ที่ไล่ดูออบเจกต์แบบซ้อนกัน (nested objects) เพื่อสร้าง path ที่คั่นด้วยจุด (dot-separated paths) สำหรับระบบ autocomplete มันทำงานได้อย่างยอดเยี่ยมกับออบเจกต์ทดสอบขนาดเล็ก แต่พอคุณนำไปใช้กับ API payload จริงๆ ตัว editor กลับค้าง และในที่สุด TypeScript ก็พ่น error TS2589: Type instantiation is excessively deep and possibly infinite. ออกมา

ข้อความนี้ไม่ได้หมายความว่าโค้ดของคุณมี infinite loop ในความหมายทั่วไป แต่มันหมายความว่าคอมไพเลอร์ (compiler) ยอมแพ้แล้ว Type ที่คุณสั่งให้มันคำนวณนั้นอาจจะไม่มีขอบเขต (unbounded) จริงๆ หรืออาจจะมีขอบเขตแต่มีขนาดใหญ่มากจนการประมวลผลนั้นเกินขีดจำกัดภายในของ TypeScript เมื่อเกิดเหตุการณ์นี้ คอมไพเลอร์จะหยุดทำงานก่อนที่จะทำให้ IDE ของคุณค้าง

เมื่อ TS2589 ปรากฏขึ้น

Recursive types คือตัวการที่พบบ่อยที่สุด TypeScript ประมวลผล type อย่างรวดเร็ว (eagerly) และหาก utility type มีการเรียกตัวเองซ้ำๆ—โดยเฉพาะผ่าน conditional logic—stack ของการคำนวณจะเพิ่มขึ้นอย่างรวดเร็ว โดยปกติแล้วคุณจะเจอกับปัญหานี้ในสถานการณ์เฉพาะบางอย่าง ดังนี้:

  • Recursive conditional types ที่ทำการ destructure tuple, object หรือ string template ซ้ำๆ จนกว่าจะถึง base case
  • Deeply nested object path generators ซึ่งเปลี่ยนโครงสร้างอย่าง { user: { address: { street: string } } } ให้กลายเป็น union ของ string literals เช่น "user" | "user.address" | "user.address.street"
  • Template literal types ที่ทำการ parse string ทีละตัวอักษรหรือทีละ token
  • Mapped types ที่วนลูปผ่านออบเจกต์ที่มีคีย์จำนวนมากและมีหลายระดับ
  • Conditional types ที่กระจายตัว (distribute) ไปบน union ขนาดใหญ่ ซึ่งเป็นการเพิ่มภาระงานในแต่ละสมาชิกอย่างเงียบๆ

ตัวอย่างเรื่อง nested path นั้นเป็นสิ่งที่ดึงดูดใจเป็นพิเศษ Library สำหรับฟอร์มและเครื่องมือจัดการ state มักจะชอบเสนอ typed paths เพื่อให้คุณได้ autocomplete สำหรับชื่อฟิลด์ สำหรับออบเจกต์ที่มีความตื้น (shallow object) การสร้าง dot-path ที่ถูกต้องทั้งหมดในรูปแบบ string union นั้นเป็นเรื่องง่าย แต่สำหรับออบเจกต์ที่ลึกหรือกว้าง union นั้นจะระเบิดตัวออก (explode) TypeScript ต้องเก็บทุกรูปแบบการสลับที่ (permutation) ไว้ในหน่วยความจำขณะทำงานพร้อมกัน เมื่อถึงระดับความลึกหนึ่ง คอมไพเลอร์จะสังเกตว่าภาระงานนั้นเกินงบประมาณที่ตั้งไว้และทำการดึงเบรกฉุกเฉินทันที

วิธีแก้ที่หนึ่ง: เพิ่มขีดจำกัดความลึก (Hard Depth Limit)

วิธีที่ตรงไปตรงมาที่สุดในการแก้ปัญหา TS2589 คือการเลิกแสร้งทำเป็นว่า type ของคุณสามารถ recurse ได้ตลอดกาล ให้คุณนำ depth counter เข้ามาใช้เพื่อทำหน้าที่เป็นตัวตัดวงจร (circuit breaker)

ในทางปฏิบัติ นี่หมายถึงการเพิ่ม numeric generic parameter—ซึ่งมักจะแสดงในรูปแบบของ tuple ที่มีความยาวลดลงเรื่อยๆ—ที่จะลดค่าลงทุกครั้งที่ type ทำการ recurse เมื่อตัวนับถึงศูนย์ type จะคืนค่าเป็น fallback กว้างๆ เช่น string แทนที่จะเจาะลึกลงไปอีก ผู้ใช้จะยังคงได้รับ autocomplete ที่แม่นยำสำหรับ 4 หรือ 5 ระดับแรก ซึ่งครอบคลุมออบเจกต์ส่วนใหญ่ในโลกความเป็นจริง ส่วนที่เกินจากนั้น คอมไพเลอร์จะเพียงแค่ขยายขอบเขตของ type (widen the type) และทำงานต่อไป

วิธีนี้ไม่ได้ทำให้ utility type ของคุณมีความถูกต้องน้อยลงในแง่ที่มีนัยสำคัญ แต่มันทำให้มันมีขอบเขต ระบบ type ที่ทำให้คอมไพเลอร์ค้างไม่ได้มีประโยชน์ไปกว่าระบบที่ยอมถอยอย่างสง่างามหลังจากถึงระดับความลึกที่เหมาะสม

วิธีแก้ที่สอง: ตรวจสอบทีละ path

หากการสร้างทุก path ที่เป็นไปได้ไว้ล่วงหน้านั้นมีราคาแพงเกินไป (ใช้ทรัพยากรมากเกินไป) ให้เปลี่ยนข้อตกลง (contract) แทนที่จะสร้าง union ขนาดมหึมาของ string ที่ถูกต้องทั้งหมด ให้เขียน type ที่ตรวจสอบว่า string เฉพาะเจาะจงหนึ่งค่า เป็น path ที่ถูกต้องหรือไม่

ลองนึกถึงความแตกต่างระหว่างการสร้างพจนานุกรมที่มีทุกคำในภาษาอังกฤษ กับการตรวจสอบว่าคำคำหนึ่งสะกดถูกต้องหรือไม่ แบบแรกคือโครงสร้างข้อมูลขนาดใหญ่ ส่วนแบบหลังคือการสแกนที่เบาบางมาก ในเชิงของ TypeScript แทนที่จะ export utility Paths<T> ที่ให้ผลลัพธ์เป็น "user.address.street" | "user.settings.theme" | ... ให้คุณ export สิ่งที่คล้ายกับ IsValidPath<T, "user.address.street"> แทน คอมไพเลอร์จะประมวลผลเฉพาะ path ที่คุณส่งเข้าไปจริงๆ เท่านั้น

การเปลี่ยนแปลงนี้จะเปลี่ยนวิธีที่คุณออกแบบ API signature ของฟังก์ชันของคุณอาจรับค่าเป็น string แล้วใช้ generic constraint เพื่อตรวจสอบกับโครงสร้างของออบเจกต์ IDE จะยังคงแจ้งเตือนหากนักพัฒนาพิมพ์ path ที่ผิด แต่คอมไพเลอร์ไม่จำเป็นต้องสร้างชุดของ path ที่ถูกต้องทั้งหมดออกมาในระหว่างการตรวจสอบ type สำหรับออบเจกต์ขนาดใหญ่ ความแตกต่างด้านประสิทธิภาพนั้นจะเห็นได้อย่างชัดเจน

กลยุทธ์ด่วนที่ช่วยให้คุณไปต่อได้

นอกเหนือจากการแก้ไขเชิงโครงสร้างทั้งสองวิธีแล้ว นิสัยเล็กๆ น้อยๆ อีกไม่กี่อย่างสามารถช่วยป้องกันไม่ให้ recursive types ทำงานเกินขอบเขตได้:

  • ห่อหุ้ม type parameters ไว้ใน tuples เพื่อบล็อกการกระจายตัว (distribution) การใช้ type parameter เปล่าๆ ใน conditional เช่น T extends Foo ? Bar : Baz จะทำให้การตรวจสอบกระจายไปยังสมาชิกทุกตัวเมื่อ T เป็น union หาก union นั้นมีสมาชิกห้าสิบตัว TypeScript จะต้องทำการ instantiate แยกกันถึงห้าสิบครั้ง การเขียน [T] extends [Foo] ? Bar : Baz จะเป็นการประเมิน conditional เพียงครั้งเดียวกับ union ทั้งหมด ใช้เทคนิคนี้เมื่อคุณไม่จำเป็นต้องให้ type ทำการ map ผ่านสมาชิกแต่ละตัวใน union แยกกัน

  • ลดขนาด input ลงในขณะที่กำลัง debug เมื่อเจอข้อผิดพลาด TS2589 ให้เปลี่ยน type ของ object ที่ใช้ใน production เป็น stub ขนาดเล็กที่มีเพียงสอง properties และมีการซ้อนกัน (nesting) เพียงชั้นเดียว หาก error หายไป แสดงว่าคุณยืนยันได้แล้วว่าปัญหาอยู่ที่ความลึก (depth) หรือจำนวนสมาชิก (cardinality) ไม่ใช่ความผิดพลาดทางไวยากรณ์ (syntax) วิธีนี้จะช่วยให้คุณไม่ต้องเสียเวลาเขียน logic ใหม่ ทั้งที่โครงสร้างเดิมนั้นถูกต้องอยู่แล้ว

  • ลดความเข้มงวดของ public-facing API types ลง ภายในระบบ คุณอาจต้องการความแม่นยำระดับศัลยกรรม แต่สำหรับภายนอก ความสมบูรณ์แบบบางครั้งก็มีราคาที่ต้องจ่ายสูงเกินความจำเป็น หากการใช้ type ที่กว้างขึ้นเล็กน้อยช่วยป้องกันอาการหน่วงสองวินาทีใน editor ได้ การแลกเปลี่ยนนี้มักจะคุ้มค่า คุณสามารถใช้ type ที่ยืดหยุ่นนี้ควบคู่ไปกับ runtime validator เพื่อดักจับเส้นทางที่ผิดพลาดในขั้นตอนการทดสอบ

ทำไม TypeScript ถึงต้องกำหนดขอบเขตนี้

TypeScript ไม่สามารถแก้ปัญหา halting problem ได้ มันไม่รู้ว่า recursive type ของคุณจะสิ้นสุดลงในที่สุดหรือจะวนลูปไปตลอดกาล แทนที่จะเสี่ยงต่อการเกิด infinite loop ภายในคอมไพเลอร์ มันจึงเลือกใช้การตัดการทำงานแบบระมัดระวัง (conservative cutoff) บางครั้งการตัดการทำงานนั้นอาจไปหยุด type ที่จริงๆ แล้ว ควรจะ ทำงานเสร็จหากมีเวลาเพียงพอ TS2589 คือการที่คอมไพเลอร์ยอมรับว่ามันขอเลือกความปลอดภัยไว้ก่อนดีกว่าต้องมาเสียใจภายหลัง

การเคารพขีดจำกัดนั้นเป็นส่วนหนึ่งของการเขียน type ระดับ production การนิยาม type คือโค้ดที่ทำงานในคอมไพเลอร์ และโค้ดที่กินทรัพยากรสูง (expensive code) ย่อมส่งผลกระทบที่แท้จริง การที่ autocomplete ช้าส่งผลเสียต่อความเร็วในการทำงานของนักพัฒนา (developer velocity) พอๆ กับที่โค้ด runtime ที่ช้าส่งผลเสียต่อประสบการณ์ของผู้ใช้งาน

บทสรุปที่สำคัญ

TS2589 ไม่ใช่สัญญาณที่บอกว่าคุณเป็นนักเขียนโปรแกรมระบบ type ที่ไม่เก่ง แต่มันเป็นสัญญาณว่า type ของคุณกำลังทำงานหนักเกินไปในคราวเดียว จำกัดการทำ recursion, ใช้การตรวจสอบแบบ lazy, และป้องกันการกระจายตัว (distribution) ที่ไม่จำเป็น เป้าหมายของ advanced types ไม่ใช่การพิสูจน์ทุกความจริงที่เป็นไปได้ในขณะ compile แต่คือการมอบเครื่องมือที่รวดเร็วและเชื่อถือได้ให้กับทีมของคุณ type ที่ compile เสร็จภายในไม่กี่มิลลิวินาทีและครอบคลุมกรณีต่างๆ ได้ถึง 95% มีค่ามากกว่า type ที่สมบูรณ์แบบในทางทฤษฎีแต่ทำให้ language server ค้าง