ทีม TypeScript หลายทีมมองว่า abstract class และ interface เป็นสิ่งที่ใช้แทนกันได้เหมือนไส้แซนด์วิชที่ต่างกันแค่ชนิด แต่จริงๆ แล้วไม่ใช่ การเลือกใช้ผิดพลาดนำมาซึ่งปัญหาที่เจ็บปวด: โลจิกทางธุรกิจที่ซ้ำซ้อน, โครงสร้างคลาสที่แข็งตัวจนยากต่อการ refactor, และข้อผิดพลาดขณะ runtime ที่สืบย้อนกลับไปหา base class ที่ใครบางคนคิดว่าปลอดภัย วิธีแก้ไม่ใช่การท่องจำกฎ แต่คือการเรียนรู้ที่จะดูความต้องการจริงของคุณและเลือกเครื่องมือที่เหมาะสม
Interface คือสัญญา (Contract) ที่บริสุทธิ์
Interface คือขอบเขตในระดับ compile-time มันอธิบายว่าออบเจกต์ควรจะมีหน้าตาอย่างไรและต้องทำอะไรได้บ้าง แต่ไม่ได้ส่งมอบ implementation ใดๆ เลย หลังจาก TypeScript ถูกคอมไพล์เป็น JavaScript แล้ว Interface จะหายไปโดยสิ้นเชิง ไม่ทิ้งทั้ง constructor, prototype chain หรือแม้แต่ byte ส่วนเกินใน bundle ของคุณ
ลองจินตนาการว่าคุณกำลังสร้างระบบแจ้งเตือน คุณกำหนด interface ขึ้นมา:
interface Notifier {
send(message: string): void;
readonly channel: string;
}
คลาสใดๆ หรือแม้แต่ object literal ธรรมดาที่ตรงตามโครงสร้างนั้น จะถือว่าเป็น Notifier ที่ถูกต้อง EmailNotifier สามารถ implement มันได้ เช่นเดียวกับ SmsNotifier, SlackNotifier หรือ mock object ที่คุณใช้ในการทำ unit test เนื่องจาก interface ไม่ได้มีโค้ดติดมาด้วย แต่ละ implementation จึงต้องเขียนเมธอด send ของตัวเองขึ้นมาใหม่ทั้งหมด และนั่นคือสิ่งที่คุณต้องการเมื่อแต่ละ implementation ไม่ได้ใช้ร่วมกับอะไรเลยนอกจากหน้าตา (public face) ของมัน
Abstract Class มาพร้อมกับโค้ดที่ใช้งานได้จริง
Abstract class คือคลาสที่สมบูรณ์แบบแต่ถูกสั่งห้ามไม่ให้สร้าง instance โดยตรง มันสามารถกำหนด fields, concrete methods ที่มี logic และ logic ใน constructor ได้ มันบังคับให้ subclass ต้องเติมเต็มส่วนที่ขาดผ่าน abstract methods แต่ในขณะเดียวกันก็มอบพฤติกรรมที่สืบทอดมา (inherited behavior) ให้โดยที่ subclass ไม่ต้องเขียนเอง
ลองนึกภาพเลเยอร์การเข้าถึงข้อมูล (data access layer) ทุกๆ repository ในแอปพลิเคชันของคุณจำเป็นต้อง parse identifier, ตรวจสอบความถูกต้องกับ schema และหลังจากนั้นจึงค่อยรัน query เฉพาะของ storage นั้นๆ Interface ไม่สามารถเก็บลำดับขั้นตอนที่ใช้ร่วมกันนี้ได้ เพราะ interface ไม่สามารถบรรจุโค้ดที่ทำงานได้ (executable code) แต่ 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 บังคับใช้โครงสร้าง มันกำหนดให้ subclass ต้อง implement fetchById แต่ในขณะเดียวกันมันก็จัดเตรียม logic ที่ใช้งานได้มาให้ด้วย Subclass จะได้รับทั้งแนวทาง (guardrails) และโค้ดพื้นฐาน (boilerplate) ไปใช้ฟรีๆ หากคุณพยายามแทนที่สิ่งนี้ด้วย interface คุณจะต้องจบลงด้วยการก๊อปปี้ validateId และ findById ลงในทุกๆ repository นั่นไม่ใช่การทำ abstraction แต่มันคือ "ภาษีในการบำรุงรักษา" (maintenance tax)
คำถามเดียวที่จะตัดสินใจได้
การตัดสินใจของคุณควรยึดตามคำถามเดียวเสมอ: สัญญา (contract) นี้จำเป็นต้องส่งมอบโค้ดที่ใช้ร่วมกันหรือไม่?
ถ้าคำตอบคือ "ไม่" ให้ใช้ interface ถ้าคำตอบคือ "ใช่" ให้ใช้ abstract class
การตัดสินใจผิดพลาดส่งผลกระทบในทันที หากคุณใช้ abstract class ในจุดที่แค่ต้องการกำหนดโครงสร้าง (shape) คุณกำลังบังคับให้ทุก implementation ต้องเข้าไปอยู่ในสายการสืบทอด (inheritance chain) การทำ unit test จะต้องมาเจอกับการทำ mocking ที่ยุ่งยากหรือต้องใช้ partial stubs ของคลาสจริง ส่วนการขยายความสามารถโดยบุคคลที่สาม (third-party extensions) ก็ต้องมาสืบทอดจาก base class ของคุณ แทนที่จะแค่ทำให้ตรงตามโครงสร้างที่กำหนด คุณได้เปลี่ยนสัญญาที่เรียบง่ายให้กลายเป็นภูเขาที่สูงชัน
ในทางกลับกัน หากคุณใช้ interface ในจุดที่มีพฤติกรรมที่ต้องใช้ร่วมกัน คุณจะกระจายโค้ดชุดเดียวกันซ้ำๆ ไปทั่ว codebase เมื่อโค้ดนั้นมี bug คุณจะไม่สามารถแก้ไขได้ในที่เดียว แต่คุณต้องไล่หาในสิบ implementation และภาวนาว่าอย่าพลาดตัวที่สิบเอ็ด
ความแตกต่างในการใช้งานจริง
นอกเหนือจากความแตกต่างในเชิงปรัชญาแล้ว ยังมีช่องว่างในเชิงปฏิบัติ 3 ประการที่แยกทั้งสองออกจากกัน
ประสิทธิภาพและขนาดของ bundle. Interface จะถูกลบออกระหว่างการคอมไพล์ พวกมันไม่เพิ่มน้ำหนักใดๆ ให้กับ JavaScript output และไม่ใช้หน่วยความจำขณะ runtime เลย ในขณะที่ abstract class จะถูกคอมไพล์เป็น constructor functions และ prototype chains จริงๆ แต่ละตัวที่คุณกำหนดจะเพิ่มขนาด bundle และกินพื้นที่ในหน่วยความจำเมื่อถูกสร้างขึ้น (instantiated) สำหรับเซิร์ฟเวอร์ที่ต้องจัดการกับอินสแตนซ์นับพัน หรือ bundle ของ frontend ที่ต้องถูกตรวจสอบอย่างเข้มงวด ความแตกต่างนี้ไม่ใช่แค่เรื่องทางทฤษฎี
ความยืดหยุ่นในการประกอบ (composition). คลาสเดียวสามารถ implement ได้หลาย interface พร้อมกัน คุณอาจสร้าง FileCache ที่ implement ทั้ง Cache, Disposable และ EventEmitter ในคราวเดียว TypeScript จะไม่มีปัญหาเพราะ interface ไม่ได้กำหนดลำดับชั้น (hierarchy) อย่างไรก็ตาม คลาสหนึ่งสามารถ extend abstract class ได้เพียงคลาสเดียวเท่านั้น เนื่องจาก prototype chain ของ JavaScript ไม่รองรับ multiple inheritance หากคุณพึ่งพา abstract class มากเกินไป ในที่สุดคุณจะเผชิญกับปัญหาคลาสสิกในการพยายามรวมสอง base class ที่ต่างก็มี
