अनेक TypeScript टीम्स abstract class आणि interface ला एकाच सँडविचमधील बदलता येण्याजोग्या फिलिंग्सप्रमाणे (fillings) समजतात. पण तसे नाहीये. चुकीचा पर्याय निवडल्यामुळे गंभीर समस्या उद्भवू शकतात: डुप्लिकेट बिझनेस लॉजिक, रिफॅक्टरिंगला विरोध करणारे ताठर क्लास ट्रीज (rigid class trees), आणि बेस क्लासमधील त्रुटींमुळे येणारे सूक्ष्म रनटाइम एरर्स. याचे निराकरण नियमावली पाठ करण्यात नाही, तर तुमच्या प्रत्यक्ष गरजा समजून घेऊन त्यांना साजेसे साधन निवडण्यात आहे.

Interfaces हे निव्वळ कॉन्ट्रॅक्ट्स (Contracts) आहेत

इंटरफेस ही एक compile-time सीमा आहे. एखादा ऑब्जेक्ट कसा दिसला पाहिजे आणि तो काय करू शकला पाहिजे, याचे वर्णन इंटरफेस करतो, परंतु तो कोणतीही implementation (अंमलबजावणी) देत नाही. TypeScript चे JavaScript मध्ये रूपांतर झाल्यानंतर, इंटरफेस पूर्णपणे नाहीसा होतो. तो कोणताही constructor, prototype chain किंवा तुमच्या bundle मध्ये अतिरिक्त bytes मागे ठेवत नाही.

समजा तुम्ही एक नोटिफिकेशन सिस्टम तयार करत आहात. तुम्ही एक इंटरफेस परिभाषित करता:

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

त्या रचनेला (shape) साजेसा कोणताही क्लास किंवा साधा object literal हा एक वैध Notifier असू शकतो. EmailNotifier त्याची अंमलबजावणी करू शकतो. तसेच SmsNotifier, SlackNotifier, किंवा युनिट टेस्टसाठी वापरलेला mock object देखील करू शकतो. इंटरफेसमध्ये कोणताही कोड नसल्यामुळे, प्रत्येक implementation स्वतःची send मेथड शून्यापासून लिहिते. जेव्हा implementations मध्ये त्यांच्या public interface व्यतिरिक्त इतर काहीही सामायिक नसते, तेव्हा तुम्हाला नेमके हेच हवे असते.

Abstract Classes मध्ये प्रत्यक्ष कोड असतो

abstract class हा एक पूर्ण क्षमतेचा क्लास असतो, ज्याची थेट instantiation (instantiation) करण्यास मनाई असते. तो fields, लॉजिकसह concrete methods आणि constructor लॉजिक परिभाषित करू शकतो. तो subclasses ला abstract methods द्वारे रिकाम्या जागा भरण्यास भाग पाडतो, परंतु त्यांना स्वतःहून लिहावे लागणार नाही असे inherited behavior देखील प्रदान करतो.

एका data access layer चा विचार करा. तुमच्या ॲप्लिकेशनमधील प्रत्येक repository ला एक identifier parse करणे, schema नुसार त्याची वैधता तपासणे आणि त्यानंतरच storage-specific query चालवणे आवश्यक असते. इंटरफेस ही सामायिक प्रक्रिया (shared sequence) पकडू शकत नाही कारण इंटरफेसमध्ये executable कोड असू शकत नाही. परंतु abstract class हे करू शकतो:

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 स्ट्रक्चर लागू करते. ते subclasses ला fetchById लागू करण्याची मागणी करते. परंतु ते कार्यरत लॉजिक देखील पुरवते. Subclasses ला guardrails आणि boilerplate विनामूल्य मिळतात. जर तुम्ही याची जागा इंटरफेसने घेण्याचा प्रयत्न केला, तर तुम्हाला प्रत्येक repository मध्ये validateId आणि findById कॉपी करावे लागतील. ही abstraction नाही, तर तो देखभालीचा अतिरिक्त भार (maintenance tax) आहे.

निर्णय घेणारा एक महत्त्वाचा प्रश्न

तुमचा निर्णय नेहमी एका प्रश्नावर आधारित असावा: या कॉन्ट्रॅक्टला सामायिक कोड (shared code) सोबत देणे आवश्यक आहे का?

जर उत्तर 'नाही' असेल, तर interface वापरा. जर उत्तर 'हो' असेल, तर abstract class वापरा.

यात चूक झाल्यास त्याचे तात्काळ परिणाम होतात. जर तुम्ही केवळ रचनेसाठी (shape) पुरेशी असलेल्या ठिकाणी abstract class वापरला, तर तुम्ही प्रत्येक implementation ला inheritance chain मध्ये अडकवता. युनिट टेस्टसाठी अचानक क्लिष्ट mocking किंवा खऱ्या क्लासचे partial stubs लागतात. थर्ड-पार्टी extensions ला केवळ रचनेशी जुळण्याऐवजी तुमच्या base क्लासचा subclass बनवावे लागते. तुम्ही एका साध्या कॉन्ट्रॅक्टचे रूपांतर एका डोंगरात केले आहे.

दुसरीकडे, जर तुम्ही सामायिक behavior असलेल्या ठिकाणी interface वापरला, तर तुम्ही तुमच्या codebase मध्ये एकाच लॉजिकच्या अनेक प्रती विखुरता. जेव्हा त्या लॉजिकमध्ये एखादी त्रुटी (bug) येते, तेव्हा तुम्हाला ती एका ठिकाणी दुरुस्त करता येत नाही. तुम्हाला दहा implementations मध्ये शोध घ्यावा लागतो आणि अकरावे सुटणार नाही अशी आशा करावी लागते.

प्रत्यक्ष वापरामध्ये ते कसे वेगळे आहेत

तात्विक फरकाव्यतिरिक्त, दोन गोष्टींमध्ये तीन व्यावहारिक फरक आहेत.

Performance आणि bundle size. इंटरफेस compilation दरम्यान काढून टाकले जातात. ते तुमच्या JavaScript आउटपुटमध्ये कोणताही भार वाढवत नाहीत आणि runtime memory वापरत नाहीत. Abstract classes चे रूपांतर प्रत्यक्ष constructor functions आणि prototype chains मध्ये होते. तुम्ही परिभाषित केलेला प्रत्येक क्लास तुमच्या bundle मध्ये भर पडतो आणि instantiate केल्यावर मेमरीमध्ये राहतो. हजारो instances हाताळणाऱ्या सर्व्हरवर किंवा बारकाईने तपासल्या जाणाऱ्या frontend bundle मध्ये, हा फरक केवळ सैद्धांतिक नाही.

Composition ची लवचिकता. एक क्लास एकाच वेळी अनेक interfaces लागू करू शकतो. तुम्ही FileCache तयार करू शकता जो Cache, Disposable, आणि EventEmitter एकाच वेळी लागू करतो. TypeScript मध्ये हे सहज शक्य आहे कारण interfaces कोणतीही hierarchy लावत नाहीत. मात्र, एक क्लास केवळ एकाच abstract class ला extend करू शकतो. JavaScript च्या prototype chain मध्ये multiple inheritance ला सपोर्ट नाही. जर तुम्ही abstract classes वर खूप जास्त अवलंबून राहिलात, तर तुम्हाला शेवटी दोन base classes एकत्र करण्याचा क्लासिक पेच (dilemma) भोगावा लागेल ज्यामध्ये दोन्ही contain करतात