אתה כותב טיפוס (type) שעובר על אובייקטים מקוננים ובנה נתיבים מופרדים בנקודה (dot-separated paths) לצורך השלמה אוטומטית (autocomplete). הוא עובד בצורה נפלאה על אובייקט בדיקה קטן. לאחר מכן אתה מפנה אותו למבנה נתונים (payload) אמיתי של API, והעורך קופא. בסופו של דבר, TypeScript פולט את השגיאה TS2589: Type instantiation is excessively deep and possibly infinite.
הודעה זו אינה אומרת שהקוד שלך מכיל לולאה אינסופית במובן המסורתי. היא אומרת שהקומפיילר ויתר. הטיפוס שביקשת ממנו לחשב היה או לא חסום באמת, או שהוא היה סופי אך גדול כל כך עד שהערכתו הייתה גומרת את המגבלות הפנימיות של TypeScript. כשזה קורה, הקומפיילר עוצר לפני שהוא תוקע את ה-IDE שלך.
מתי TS2589 מופיע
טיפוסים רקורסיביים הם האשמים הנפוצים ביותר. TypeScript מעריך טיפוסים בצורה אקטיבית (eagerly), ואם טיפוס עזר (utility type) ממשיך לקרוא לעצמו – במיוחד דרך לוגיקה מותנית – מחסנית החישוב גדלה במהירות. בדרך כלל תיתקלו בקיר הזה בכמה תרחישים ספציפיים:
- Recursive conditional types שמפרקים שוב ושוב tuple, אובייקט או template string עד להגעה למקרה בסיס (base case)
- מחוללי נתיבי אובייקט מקוננים עמוק, שהופכים מבנים כמו
{ user: { address: { street: string } } }לאיחודים (unions) של string literals כגון"user" | "user.address" | "user.address.street" - Template literal types שמפרקים מחרוזות תו-אחר-תו או טוקן-אחר-טוקן
- Mapped types שרצים על אובייקטים עם עשרות מפתחות ורמות מרובות
- Conditional types שמתפלגים על פני unions גדולים, ומכפילים בשקט את עומס העבודה על כל איבר
דוגמת הנתיב המקונן היא מפתה במיוחד. ספריות טפסים וכלי ניהול מצב (state-management) אוהבים להציע נתיבים מוקצבים (typed paths) כדי שתקבלו השלמה אוטומטית לשמות שדות. באובייקט רדוד, יצירת כל נתיב נקודה חוקי כ-string union היא פשוטה. באובייקט עמוק או רחב, האיחוד הזה מתפוצץ. TypeScript חייב להחזיק כל פרמוטציה בזיכרון העבודה בבת אחת. בעומק מסוים, הקומפיילר מבחין שהעבודה עולה על התקציב שלו ומושך את בלם החירום.
פתרון ראשון: הוספת מגבלת עומק קשיחה
הדרך הישירה ביותר לפתור את TS2589 היא להפסיק להעמיד פנים שהטיפוס שלך יכול להמשיך ברקורסיה לנצח. הכניסו מונה עומק שמתפקד כ"מפסק ביטחון" (circuit breaker).
בפועל, זה אומר הוספת פרמטר גנרי מסוג מספר – שלעיתים קרובות מיוצג כ-tuple שאורכו יורד בספירה לאחור – שפוחת בכל פעם שהטיפוס מבצע רקורסיה. כאשר המונה מגיע לאפס, הטיפוס מחזיר ברירת מחדל רחבה כמו string במקום להמשיך לחפור עמוק יותר. המשתמשים עדיין יקבלו השלמה אוטומטית מדויקת לארבע או חמש הרמות הראשונות, מה שמכסה את הרוב המכריע של האובייקטים בעולם האמיתי. מעבר לכך, הקומפיילר פשוט מרחיב את הטיפוס וממשיך הלאה.
גישה זו אינה הופכת את טיפוס העזר שלכם לפחות נכון בשום צורה משמעותית. היא הופכת אותו לחסום (bounded). מערכת טיפוסים שגורמת לקריסה של הקומפיילר אינה שימושית יותר ממערכת שמתפשרת בצורה מכובדת לאחר עומק סביר.
פתרון שני: אימות נתיב אחד בכל פעם
אם יצירת כל הנתיבים האפשריים מראש היא יקרה מדי, שנו את החוזה (contract). במקום להפיק union עצום של כל המחרוזות התקפות, כתבו טיפוס שבודק האם מחרוזת ספציפית אחת היא נתיב תקף.
חשבו על ההבדל בין יצירת מילון של כל מילה באנגלית לבין בדיקה אם מילה בודדת מאויתת נכון. הראשון הוא מבנה נתונים עצום; האחרון הוא סריקה קלה. במונחים של TypeScript, במקום לייצא utility מסוג Paths<T> שמחזיר "user.address.street" | "user.settings.theme" | ..., ייצאו משהו כמו IsValidPath<T, "user.address.street">. הקומפיילר מעריך רק את הנתיב שאתם באמת מעבירים.
השינוי הזה משנה את האופן שבו אתם מעצבים APIs. חתימות הפונקציות שלכם עשויות לקבל מחרוזת ואז להשתמש באילוץ גנרי (generic constraint) כדי לאמת אותה מול מבנה האובייקט. ה-IDE עדיין יתלונן אם המפתח יקליד נתיב שגוי, אך הקומפיילר לעולם לא יצטרך ליצור את קבוצת הנתיבים החוקיים המלאה במהלך בדיקת הטיפוסים. עבור אובייקטים גדולים, ההבדל בביצועים הוא דרמטי.
טקטיקות מהירות שיאפשרו לכם להמשיך הלאה
מעבר לשני הפתרונות המבניים, כמה הרגלים קטנים יותר יכולים למנוע מטיפוסים רקורסיביים לחצות את הגבול:
עטפו פרמטרים של טיפוסים בתוך טאפלים (tuples) כדי לחסום התפלגות (distribution). פרמטר טיפוס "חשוף" (naked) בתוך תנאי, כמו
T extends Foo ? Bar : Baz, מפיץ את הבדיקה על כל איבר כאשרTהוא union. אם ל-union הזה יש חמישים איברים, TypeScript מבצע חמישים מימושים (instantiations) נפרדים. כתיבת[T] extends [Foo] ? Bar : Bazמעריכה את התנאי פעם אחת מול כל ה-union. השתמשו בזה בכל פעם שאינכם זקוקים בפועל לכך שהטיפוס יבצע מיפוי (map) על כל איבר ב-union באופן פרטני.צמצמו את הקלטים שלכם בזמן ניפוי שגיאות (debugging). כאשר מופיעה השגיאה TS2589, החליפו את טיפוס האובייקט מהסביבה הייצורית (production) ב-stub קטן עם שתי תכונות ורמת קינון (nesting) אחת. אם השגיאה נעלמת, אישרתם שהבעיה היא בעומק או בכמות האיברים (cardinality), ולא בשגיאת תחביר. זה יחסוך לכם מכתיבה מחדש של לוגיקה שהייתה תקינה מבחינה מבנית.
הרפו את טיפוסי ה-API הפונים לציבור (public-facing). מבחינה פנימית, ייתכן שתזדקקו לדיוק כירורגי. מבחינה חיצונית, שלמות לפעמים עולה יותר ממה שהיא מניבה. אם טיפוס autocomplete מעט רחב יותר מונע השהיה (lag) של שתי שניות בעורך, ההחלפה בדרך כלל שווה את זה. ניתן לשלב את הטיפוס המשוחרר יותר עם ולידטור (validator) בזמן ריצה כדי לתפוס נתיבים שגויים בבדיקות.
למה TypeScript אוכפת את הגבול הזה
TypeScript אינה יכולה לפתור את בעיית העצירה (halting problem). היא אינה יודעת אם הטיפוס הרקורסיבי שלכם יסתיים בסופו של דבר או יתגלגל לנצח. במקום להסתכן בלולאה אינסופית בתוך הקומפיילר, היא אוכפת חיתוך שמרני. לעיתים החיתוך הזה תופס טיפוס שהיה מסתיים, לו ניתנה לו מספיק זמן. TS2589 הוא ההודאה של הקומפיילר שהוא מעדיף להיות בטוח מאשר להצטער.
כיבוד הגבול הזה הוא חלק מכתיבת טיפוסים ברמת ייצור (production-grade). הגדרת טיפוס היא קוד שרץ בתוך הקומפיילר, ולקוד יקר (במונחי ביצועים) יש השלכות ממשיות. autocomplete איטי פוגע במהירות הפיתוח בדיוק כפי שקוד איטי בזמן ריצה פוגע בחוויית המשתמש.
השורה התחתונה
TS2589 אינו סימן לכך שאתם מתכנתים גרועים של מערכות טיפוסים. זהו סימן לכך שהטיפוס שלכם מבצע יותר מדי עבודה בבת אחת. הגבילו את הרקורסיה שלכם, בצעו ולידציה בצורה עצלה (lazily), והגנו מפני התפלגות מיותרת. המטרה של טיפוסים מתקדמים אינה להוכיח כל אמת אפשרית בזמן קומפילציה; המטרה היא לספק לצוות שלכם כלי עבודה מהירים ואמינים. טיפוס שמתקמפל במילישניות ומכסה תשעים וחמישה אחוז מהמקרים הוא בעל ערך רב הרבה יותר מטיפוס שהוא מושלם תיאורטית אך גורם לקריסה של ה-language server.
