פרויקטים של TypeScript גדלים. קבצים מתרבים. תלויות מסתבכות. ובסופו של דבר, ה-build שלכם נתקל בקיר שאין לו שום קשר למורכבות הלוגיקה שלכם, אלא להכל: הצורך של הקומפיילר לקרוא את כל היקום לפני שהוא יכול לכתוב קובץ declaration אחד.
TypeScript 6.0 נותן מענה לכך באמצעות isolatedDeclarations. התכונה הזו חושבת מחדש על האופן שבו קבצי .d.ts נוצרים. במקום לקשור את הפקת ה-declarations לתהליך ה-type-checking המלא, היא מאפשרת לקומפיילר להפיק את הקבצים הללו על ידי בחינה של כל קובץ מקור בבידוד. התוצאה היא תהליך build שיכול לרוץ במקביל על פני אלפי קבצים, במקום לזחול דרך גרף התלויות שלכם קישור אחר קישור.
צוואר הבקבוק האמיתי
כרגע, יצירת קבצי declaration היא פעולה סדרתית. כשאתם מפעילים את --declaration ומריצים את הקומפיילר, TypeScript לא יכול להפיק קובץ .d.ts עבור מודול מסוים עד שהוא מבין לחלוטין כל type שהמודול הזה נוגע בו. אם utils.ts מייבא types מ-types.ts, ו-types.ts מושך משהו מ-api.ts, הקומפיילר חייב לפתור את השרשרת הזו לפני שהוא יכול לתאר מה utils.ts מייצא (exports).
ב-monorepo גדול, השרשרת הזו היא אכזרית. קובץ בודד בקרבת השורש של גרף ה-imports שלכם יכול לחסום הפקת declaration עבור מאות קבצים בהמשך השרשרת. ל-CPU שלכם יש שמונה ליבות, אבל שבע מהן יושבות ללא מעש בזמן ש-TypeScript משחזר בקושי רב את המבנה של כל interface מעבר לגבולות החבילות (packages). הקומפיילר מבצע עבודה נחוצה, אך הצימוד (coupling) בין type checking להפקת declaration אומר שאתם משלמים את המחיר המלא של ניתוח חוצה-קבצים, גם כשאתם רוצים רק שה-public surface types ייכתבו לדיסק.
איך isolatedDeclarations משנה את הכללים
isolatedDeclarations שובר את הצימוד הזה. כשמדליקים את הדגל, הקומפיילר מסכים להפיק קובץ .d.ts עבור קובץ מקור מבלי לשאול אף קובץ אחר מה המשמעות של דבר מה. הוא עושה זאת על ידי דרישה לחוזה פשוט: כל symbol שמוצא (exported) חייב לשאת type annotation מפורש ונראה בנקודה שבה הוא מוצהר.
אם הקומפיילר יכול לראות את ה-type המלא כתוב ממש שם בקוד המקור, הוא לא צריך לבצע inference. הוא לא צריך לרדוף אחרי imports. הוא לא צריך לדעת אם ה-identifier User בקובץ אחר הוא interface, type alias, או class. הוא פשוט מפיק בדיוק את מה שכתבתם.
זה אומר שקובץ A וקובץ B יכולים לייצר את ה-declarations שלהם בו-זמנית. build orchestrator יכול להעביר כל קובץ ל-thread נפרד. transpilers מהירים שבעבר דילגו על יצירת .d.ts כי חסר להם type checker מלא, יכולים כעת גם הם להפיק קבצי declaration, מכיוון שהעבודה הופכת לתחבירית (syntactic) בלבד.
הפשרה: תכתבו את זה במפורש
המהירות לא מגיעה בחינם. עליכם להפסיק להסתמך על type inference עבור כל דבר שאתם מייצאים (export). לכל function, class, variable ו-constant ציבוריים (public) יש צורך ב-type מפורש וברור. אם TypeScript צריך לחשב את ה-type על ידי בדיקת statement של return או פתרון generic argument, isolatedDeclarations יזרוק שגיאה.
כך זה נראה בפועל. ללא הדגל, אולי תכתבו:
export function fetchUser(id: number) {
return fetch(`/users/${id}`).then(r => r.json());
}
TypeScript מבצע inference ל-return type על ידי בדיקת fetch, לאחר מכן Promise.prototype.then, ואז הפונקציה האנונימית שמחזירה r.json(). כדי להפיק .d.ts, הקומפיילר צריך לבצע את כל הניתוח הזה.
עם isolatedDeclarations מופעל, עליכם להוסיף annotation ל-export:
interface User {
id: number;
email: string;
}
export function fetchUser(id: number): Promise<User> {
return fetch(`/users/${id}`).then(r => r.json());
}
כעת הקומפיילר רואה Promise<User> באופן מיידי. הוא מפיק את ה-declaration וממשיך הלאה.
הכלל הזה תקף באופן נרחב. מערכים (arrays) שמוצאים (exported) זקוקים ל-types מפורשים במקום להסתמך על inference מהאיברים שלהם. אובייקטים שמוצאים זקוקים ל-type annotations מפורשים אם המבנה (shape) שלהם חשוב לצדדים שמשתמשים בהם (consumers). פונקציות גנריות (generic functions) זקוקות לכך שה-return types וה-constraints שלהן יהיו גלויים בנקודת ההצהרה. אי אפשר לייצא תוצאה של mapped type מורכב מבלי לתת לו named type alias שכתוב במלואו.
היתרון הוא שה-public API שלכם הופך להיות self-documenting. ה-consumers — והקומפיילר — כבר לא צריכים לבצע reverse-engineering לכוונתכם מתוך פרטי המימוש. ה-types הם חוזה מכוון.
לאן הולך הזמן
בבסיס קוד (codebase) גדול, ההשפעה היא מיידית. זמני build שנמשכים דקות יכולים לצנוח לשניות, מכיוון שהפקת ה-declarations מפסיקה להיות צוואר הבקבוק המרכזי. כל קובץ מופק באופן עצמאי, כך שהתהליך מתרחב (scales) בהתאם למספר הליבות שיש לכם, ולא בהתאם לעומק גרף ה-imports שלכם.
זה גם משנה את הכלים שבהם ניתן להשתמש. טרנספיילרים כמו esbuild ו-swc הם כבר מהירים להפליא בהפיכת TypeScript ל-JavaScript, אך צוותים רבים עדיין מריצים את tsc בנפרד רק כדי להפיק קבצי .d.ts. עם isolatedDeclarations, הכלים המהירים הללו יכולים לבצע את שתי המשימות. הם לא צריכים לשחזר את כל מערכת הטיפוסים של TypeScript כדי ליצור הצהרות (declarations); הם רק צריכים לנתח את התחביר ולהעתיק את הטיפוסים המפורשים שסיפקת. זה הופך תהליכי build של TypeScript מקצה לקצה עם שרשראות כלים (toolchains) חלופיות להרבה יותר ישימים.
גם תהליכי build מבוזרים ואינקרמנטליים הופכים לפשוטים יותר. בשילוב רציף (continuous integration), מטמון מרוחק (remote cache) או build מחולק (sharded build) יכולים להפיק הצהרות עבור חבילה מבלי להוריד תחילה את כל גרף התלויות הטרנזיטיבי שלו. אם הטיפוסים מפורשים במקור, לחלק ה-build יש את כל מה שהוא צריך.
מה נשאר אותו דבר
המגבלה חלה רק על exports. בתוך מודול, הכל ממשיך כרגיל. משתנים מקומיים, חברים פרטיים במחלקה (private class members) ופונקציות עזר שאינן מיוצאות (unexported) יכולים עדיין להסתמך על הסקת טיפוסים (type inference) מלאה. TypeScript יסיק בשמחה את הטיפוס של משתנה לולאה או פרמטר של closure ללא תלונה.
export function calculateTotal(items: Item[]): number {
// Local variable: inference is fine
const taxRate = 0.08;
// Private class member inside a local class: inference is fine
class Helper {
private cache = new Map();
}
return items.reduce((sum, item) => sum + item.price * (1 + taxRate), 0);
}
רק לחתימת הפונקציה המיוצאת (exported function signature) נדרשה הערה (annotation). המנגנון הפנימי נשאר גמיש ובעל יכולת ביטוי גבוהה. זה שומר על עומס הכתיבה ברמה נסבלת. אינכם עוברים לסגנון מפורש לחלוטין בכל מקום; אתם פשוט מעצבים רשמית את החוזה (contract) בגבול של כל מודול.
האם זה מתאים לבסיס הקוד שלכם?
אימוץ isolatedDeclarations משנה את האופן שבו אתם משקיעים את זמנכם. אתם משקיעים כמה הקשות נוספות כשאתם כותבים export, ובתמורה אתם מפסיקים לשלם "ריבית" על כל build. עבור כותבי ספריות, זהו לרוב "מכירה קלה" (easy sell). כנראה ש-Public APIs צריכים להיות עם annotations בכל מקרה. עבור מפתחי אפליקציות שעובדים בתוך monorepo סגור, העלות הראשונית עשויה להרגיש כמו טקס מיותר. אך אם הצוות שלכם מודד זמן build בהפסקות קפה, ההחלפה הופכת לאטרקטיבית במהירות.
ניתן לאמץ זאת באופן אינקרמנטלי. הפעילו את ה-flag, הריצו את הקומפיילר, ותקנו את השגיאות שהוא מעלה על סמלים מיוצאים (exported symbols). הודעות השגיאה יגידו לכם בדיוק אילו טיפוסים הפונים לציבור (public-facing types) הם משתמעים (implicit). תקנו אותם, השאירו את הפנימיים כפי שהם, וצפו בשלב יצירת ההצהרות (declaration step) שלכם מאיץ.
דבר אחד שכדאי לזכור: ה-flag הזה לא הופך את ה-type checker של TypeScript למהיר יותר בעצמו. אם אתם רוצים משוב מהיר יותר בעורך (editor) או הרצות מהירות יותר של tsc --noEmit, אתם עדיין זקוקים ל-project references, הכללה מחמירה יותר של קבצים, או תיקונים ארכיטקטוניים אחרים. isolatedDeclarations מתמקד ספציפית בהפקה (emission) של קבצי .d.ts. זוהי אופטימיזציה של ה-build, ולא אופטימיזציה של ה-type-checking.
השורה התחתונה
isolatedDeclarations מבקש מכם להתייחס לטיפוסים הציבוריים שלכם כאל אובייקטים מסוג first-class. הפסיקו לגרום לקומפיילר להסיק אותם. תכתבו אותם במפורש. ברגע שתעשו זאת, הקומפיילר יפסיק לסרוק את כל גרף התלויות שלכם בכל פעם שהוא צריך ליצור קובץ הצהרה (declaration file). הוא מפיק (emits) במקביל, כלים כמו esbuild ו-swc מטפלים בתהליכי עבודה (workflows) מלאים של TypeScript, וה-builds של ה-monorepo שלכם מפסיקים להיגרר.
העלות עוברת מזמן build לזמן כתיבה. עבור רוב הצוותים הצומחים, זהו טרייד-אוף ששווה לעשות.
