צוותי TypeScript רבים מתייחסים ל-abstract class ול-interface כאל מילויים חליפיים לאותו כריך. הם לא. הבחירה הלא נכונה מביאה תוצאות כואבות: לוגיקה עסקית כפולה, עצי מחלקות נוקשים שמתנגדים לכל refactor, ושגיאות runtime דקות ששורשן במחלקת בסיס שמישהו הניח שהיא בטוחה. הפתרון אינו שינון ספר חוקים, אלא למידה כיצד להסתכל על הדרישות האמיתיות שלך ולבחור בכלי המתאים להן.

ממשקים (Interfaces) הם חוזים טהורים

ממשק הוא גבול בזמן קומפילציה. הוא מתאר איך אובייקט חייב להיראות ומה הוא חייב להיות מסוגל לעשות, אך הוא אינו כולל שום מימוש (implementation). לאחר ש-TypeScript מתקמפל ל-JavaScript, הממשק נעלם לחלוטין. הוא אינו משאיר קונסטרקטור (constructor), שרשרת פרוטוטיפים (prototype chain) או בתים נוספים ב-bundle שלך.

דמיינו שאתם בונים מערכת התראות. אתם מגדירים ממשק:

interface Notifier {
  send(message: string): void;
  readonly channel: string;
}

כל מחלקה, או אפילו אובייקט ליטרלי (object literal) פשוט, שעונה על המבנה הזה היא Notifier תקף. EmailNotifier יכול לממש אותו. כך גם SmsNotifier, SlackNotifier, או אובייקט mock שאתם מעבירים לבדיקת יחידה (unit test). מכיוון שהממשק אינו מכיל קוד, כל מימוש כותב את מתודת ה-send שלו מאפס. זה בדיוק מה שאתם רוצים כשהמימושים אינם חולקים דבר מלבד הממשק הציבורי שלהם.

מחלקות מופשטות (Abstract Classes) נושאות קוד אמיתי

מחלקה מופשטת היא מחלקה מלאה במלואה שפשוט אוסרת על יצירת מופע (instantiation) ישירה. היא יכולה להגדיר שדות, מתודות קונקרטיות עם לוגיקה, ולוגיקת קונסטרקטור. היא מחייבת תתי-מחלקות למלא את החסר באמצעות מתודות מופשטות, אך היא גם מעניקה להן התנהגות ירושה שהן לא צריכות לכתוב בעצמן.

דמיינו שכבת גישה לנתונים (data access layer). כל repository באפליקציה שלכם צריך לנתח מזהה (identifier), לתקף אותו מול סכימה, ורק אז להריץ שאילתה ספציפית לאחסון. ממשק אינו יכול לתפוס את הרצף המשותף הזה מכיוון שממשק אינו יכול להכיל קוד בר-ביצוע. מחלקה מופשטת יכולה:

abstract class BaseRepository<T> {
  protected validateId(id: string): boolean {
    return /^[a-z0-9\-]+$/.test(id);
  }

  abstract fetchById(id: string): Promise<T | null>;

  async findById(id: string): Promise<T | null> {
    if (!this.validateId(id)) throw new Error("Invalid ID format");
    return this.fetchById(id);
  }
}

ה-BaseRepository אוכפת מבנה. היא דורשת מתתי-מחלקות לממש את fetchById. אך היא גם מספקת לוגיקה עובדת. תתי-מחלקות מקבלות את המגבלות (guardrails) ואת ה-boilerplate בחינם. אם הייתם מנסים להחליף זאת בממשק, הייתם מסיימים בהעתקת validateId ו-findById לכל repository בנפרד. זו אינה הפשטה (abstraction). זוהי "מס תחזוקה".

השאלה היחידה שמכריעה

הבחירה שלכם צריכה תמיד להתמצת בשאלה אחת: האם החוזה הזה צריך לספק קוד משותף?

אם התשובה היא לא, השתמשו בממשק. אם התשובה היא כן, השתמשו במחלקה מופשטת.

טעות בבחירה זו תביא להשלכות מיידיות. אם תשתמשו במחלקה מופשטת במקום שבו מבנה (shape) היה מספיק, תכריחו כל מימוש להיכנס לשרשרת ירושה. בדיקות יחידה ידרשו פתאום mocking מסורבל או stubs חלקיים של מחלקה אמיתית. הרחבות מצד שלישי יצטרכו ליצור תת-מחלקה לבסיס שלכם במקום פשוט להתאים למבנה מסוים. הפכתם חוזה פשוט להר גבוה.

מצד שני, אם תשתמשו בממשק במקום שבו קיימת התנהגות משותפת, תפזרו עותקים של אותה לוגיקה בכל בסיס הקוד שלכם. כשבתוך הלוגיקה הזו יש באג, לא תוכלו לתקן אותו במקום אחד. תצטרכו לצוד אותו בין עשרה מימושים ולקוות שלא פספסתם את האחד-עשר.

איך הם באמת שונים בפועל

מעבר להבדל הפילוסופי, שלושה פערים מעשיים מפרידים ביניהם.

ביצועים וגודל ה-bundle. ממשקים נמחקים במהלך הקומפילציה. הם מוסיפים אפס משקל לפלט ה-JavaScript שלכם ואינם צורכים זיכרון בזמן ריצה (runtime). מחלקות מופשטות מתקמפלות לפונקציות קונסטרקטור אמיתיות ולשרשראות פרוטוטיפים. כל אחת שאתם מגדירים מוסיפה ל-bundle שלכם ויושבת בזיכרון בעת יצירת מופע. בשרת שמטפל באלפי מופעים, או ב-bundle של צד-לקוח (frontend) שנמצא תחת בחינה, ההבדל הזה אינו תיאורטי.

גמישות של הרכבה (composition). מחלקה אחת יכולה לממש ממשקים רבים בבת אחת. אתם יכולים לבנות FileCache שמממש את Cache, Disposable, ו-EventEmitter בבת אחת. TypeScript תהיה מרוצה מכיוון שממשקים אינם כופים היררכיה. מחלקה, לעומת זאת, יכולה להרחיב (extend) רק מחלקה מופשטת אחת. שרשרת הפרוטוטיפים של JavaScript אינה תומכת בירושה מרובה. אם תסתמכו יותר מדי על מחלקות מופשטות, תתמודדו בסופו של דבר עם הדילמה הקלאסית של ניסיון למזג שתי מחלקות בסיס ששתיהן מכילות