در یکی از پروژه‌های Node.js که به یک تیم دیگر تحویل داده بودم، بعد از سه ماه یک تماس گرفتم تا وضعیت را جویا شوم. مدیر فنی با لحنی خسته گفت: «پروژه کار می‌کند اما هیچ‌کس جرأت نمی‌کند به کلاس‌های اصلی دست بزند.» وقتی کد را باز کردم، فهمیدم مشکل نه در منطق بود و نه در معماری؛ مشکل در طراحی کلاس‌ها بود. صدها خط منطق در constructor ریخته شده بود، متدها بدون هیچ access modifier نوشته شده بودند، و کلاس‌های فرزند با override کردن نادرست متدهای پدر، رفتارهای غیرقابل پیش‌بینی می‌ساختند. آن تجربه، درک من از کلاس در TypeScript را تغییر داد: class فقط یک ابزار برای ساخت آبجکت نیست؛ یک قرارداد رفتاری است که اگر درست طراحی نشود، پروژه را به یک هزارتوی غیرقابل نگهداری تبدیل می‌کند.

class در TypeScript نسخه‌ی تکامل‌یافته‌ی class در جاوااسکریپت است که با یک لایه‌ی تایپ‌چکینگ، امکانات جدید و کنترل دقیق‌تر روی دسترسی‌ها ارائه می‌شود. اگر در مسیر آموزش تایپ اسکریپت از صفر هستید و مقالات اینترفیس در تایپ اسکریپت و جنریک در تایپ اسکریپت را خوانده‌اید، این نوشته تمرکز دقیق روی یکی از پرکاربردترین و در عین حال پرخطرترین ابزارهای شی‌گرایی را باز می‌کند.

چرا class در TS هم قدرتمند است و هم خطرناک؟

class در TypeScript یک ابزار دو لبه است. از یک سو، امکانات قدرتمندی مثل access modifier، abstract و interface implementation را در اختیار شما می‌گذارد که در جاوااسکریپت خالص وجود ندارد. از سوی دیگر، همین قدرت اگر درست کنترل نشود، پروژه را به سمت پیچیدگی و بدهی فنی می‌برد. سه دلیل که این ابزار را جدی می‌گیرم:

  • class یک قرارداد رفتاری است، نه فقط یک ظرف داده: وقتی یک class طراحی می‌کنید، فقط فیلدها و متدها را تعریف نمی‌کنید؛ قراردادی برای نحوه‌ی تعامل با آن شیء می‌سازید. اگر این قرارداد ناقص باشد، مصرف‌کننده‌های آن class به رفتار غیرقابل پیش‌بینی می‌رسند. مطالعه‌ی این لایه در اینترفیس در تایپ اسکریپت آمده است.
  • class حالت (state) نگه می‌دارد: برخلاف توابع Pure که فقط ورودی به خروجی تبدیل می‌کنند، class می‌تواند وضعیت داخلی داشته باشد. این ویژگی هم مزیت است (می‌توانید منطق پیچیده را در یک موجودیت کپسوله کنید) و هم خطر (حالت داخلی می‌تواند باعث رفتار غیرقابل پیش‌بینی در شرایط نادر شود).
  • class با ارث‌بری پیچیده می‌شود: ارث‌بری در کلاس‌ها ذاتاً شکننده است. اگر عمق درخت ارث‌بری زیاد شود، تغییر در یک کلاس پایه، تمام فرزندان را تحت تأثیر قرار می‌دهد. در پروژه‌های بزرگ، این شکنندگی به یک بدهی فنی تبدیل می‌شود. مطالعه‌ی این موضوع در چارچوب OOP در شی‌گرایی در تایپ اسکریپت آمده است.

قاعده‌ی من در پروژه‌ها: از class برای مدل‌سازی موجودیت‌هایی استفاده کنید که واقعاً «رفتار + حالت» دارند، نه برای هر داده‌ای که می‌شود با یک interface یا type ساده پوشش داد. این یک تصمیم معماری است که در پروژه‌های بلندمدت، تفاوت را می‌سازد.

class در TypeScript ابزاری است که اگر درست به‌کار نرود، به‌جای ساده‌کردن، پیچیدگی اضافه می‌کند. قدرتِ واقعیِ آن، در درست به‌کار بردنش است نه در به‌کار بردنش.

ساختار پایه‌ی یک class در تایپ اسکریپت

ساختار پایه‌ی یک class در TS، نسخه‌ی تایپ‌دار شده‌ی همان ساختار در جاوااسکریپت است:

class User {
  id: number;
  name: string;
  email: string;

  constructor(id: number, name: string, email: string) {
    this.id = id;
    this.name = name;
    this.email = email;
  }

  greet(): string {
    return `Hello, ${this.name}`;
  }
}

const user = new User(1, "Ali", "ali@example.com");
console.log(user.greet());

سه نکته‌ی مهم که در روزهای اولیه یادگیری TS به‌کارم آمده:

  • تعریف فیلدها در بالای کلاس: در TS، باید فیلدهای کلاس را قبل از استفاده در constructor تعریف کنید. برخلاف جاوااسکریپت که این‌کار اختیاری است، در TS این یک الزام است.
  • تایپ فیلدها اجباری است: اگر id: number را ننویسید، TS خطا می‌دهد. این یک لایه‌ی محافظت اولیه است.
  • متدها تایپ بازگشتی دارند: برای greet(): string صریحاً نوع بازگشتی را نوشتم. این کار اختیاری است اما در پروژه‌های بزرگ توصیه می‌شود چون به کامپایلر اجازه می‌دهد سریع‌تر خطاهای ناشی از تغییرات را بگیرد.

اگر با کلاس‌های جاوااسکریپت آشنایی ندارید، پیشنهاد می‌کنم قبل از ادامه، شی گرایی در جاوااسکریپت را بخوانید؛ چون بسیاری از مفاهیم TS در این حوزه، تکامل همان مفاهیم در JS هستند.

public، private، protected: کنترل دقیق دسترسی

یکی از مهم‌ترین امکانات class در TS، access modifierها هستند که در جاوااسکریپت خالص وجود ندارند:

class BankAccount {
  public owner: string;               // پیش‌فرض، از هر جا قابل دسترسی
  private balance: number;            // فقط داخل کلاس
  protected accountNumber: string;    // داخل کلاس و کلاس‌های فرزند

  constructor(owner: string, balance: number) {
    this.owner = owner;
    this.balance = balance;
    this.accountNumber = this.generateAccountNumber();
  }

  private generateAccountNumber(): string {
    return Math.random().toString(36).substring(2, 10);
  }

  public deposit(amount: number): void {
    if (amount > 0) {
      this.balance += amount;
    }
  }

  public getBalance(): number {
    return this.balance;
  }
}

const account = new BankAccount("Ali", 1000);
account.deposit(500);
console.log(account.getBalance());   // OK
console.log(account.balance);         // خطای کامپایل!
console.log(account.accountNumber);   // خطای کامپایل!

تفاوت این سه modifier در یک جدول:

Modifierداخل کلاسکلاس فرزندخارج از کلاس
publicدسترسیدسترسیدسترسی
protectedدسترسیدسترسیبدون دسترسی
privateدسترسیبدون دسترسیبدون دسترسی

نکته‌ی مهم: access modifierها در TS فقط در زمان کامپایل بررسی می‌شوند. یعنی در runtime، کد جاوااسکریپت هیچ محافظتی روی private ندارد — هر کسی می‌تواند از بیرون به آن دسترسی پیدا کند. اگر به محافظت واقعی در runtime نیاز دارید، از #private (private fields با سینتکس #) استفاده کنید که در نسخه‌های جدید استاندارد شده است.

قاعده‌ی من در پروژه‌ها: هر فیلد یا متد را با modifier مناسب علامت‌گذاری کنید. حتی اگر پیش‌فرض public است، صریحاً بنویسید تا خواننده‌ی کد بداند این تصمیم آگاهانه بوده است.

readonly در class و تفاوتش با const

readonly در class به‌معنای «فقط یک بار قابل تخصیص» است. یعنی فیلد در constructor قابل تنظیم است اما بعد از آن نمی‌توان تغییرش داد:

class Config {
  readonly apiUrl: string;
  readonly timeout: number;

  constructor(apiUrl: string, timeout: number) {
    this.apiUrl = apiUrl;
    this.timeout = timeout;
  }
}

const config = new Config("https://api.example.com", 5000);
config.apiUrl = "https://other.com";  // خطای کامپایل!

تفاوت readonly با const:

  • const: برای متغیرهای غیرقابل تخصیص در scope تابع یا ماژول استفاده می‌شود.
  • readonly: برای فیلدهای کلاس استفاده می‌شود و در زمان ساخت (constructor) قابل تخصیص است اما بعد از آن نه.

ترکیب این دو در پروژه‌های بزرگ، یکی از آن تصمیم‌های کوچک است که در بلندمدت، باگ‌های پنهان را کاهش می‌دهد. در پروژه‌ای که یک سیستم پرداخت داشتیم، تمام فیلدهای شناسه (transactionId، userId، timestamp) با readonly علامت‌گذاری شده بودند و این باعث شد یک باگ بالقوه که در آن شناسه‌ی تراکنش در میانه‌ی اجرا تغییر می‌کرد، در زمان کامپایل گرفته شود.

Parameter Properties: سینتکس کوتاه اما پرخطر

TypeScript یک میان‌بر سینتکتی به‌نام Parameter Properties دارد که در constructor، هم پارامتر را تعریف می‌کند و هم فیلد کلاس را:

// حالت معمول
class User {
  id: number;
  name: string;

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }
}

// با Parameter Properties
class User {
  constructor(
    public id: number,
    public name: string,
    private email: string
  ) {}
}

نسخه‌ی دوم بسیار کوتاه‌تر است اما سه دام دارد که در پروژه‌ها دیده‌ام:

  1. کاهش خوانایی در کلاس‌های بزرگ: وقتی constructor با هفت یا هشت parameter properties پر می‌شود، خواندن آن دشوار می‌شود چون فیلدهای کلاس پراکنده به‌نظر می‌رسند.
  2. مشکل در افزودن منطق: اگر بخواهید در constructor اعتبارسنجی یا تبدیل انجام دهید، باید کد را در بدنه‌ی constructor بنویسید و این کمی غیرشهودی است.
  3. ناسازگاری با بعضی ابزارها: بعضی ابزارهای refactoring یا تحلیل کد، Parameter Properties را درست تشخیص نمی‌دهند و این می‌تواند در بازبینی کد مشکل ایجاد کند.

پیشنهاد من در پروژه‌ها: از Parameter Properties برای کلاس‌های کوچک (دو یا سه پارامتر) استفاده کنید و برای کلاس‌های پیچیده‌تر، حالت معمول را ترجیح دهید. ترجیح خود من: در پروژه‌های تیمی، حالت معمول را برای همه‌ی کلاس‌ها انتخاب کنیم تا یکدست باشد.

ارث‌بری (extends) و مدیریت super

ارث‌بری در class با extends انجام می‌شود:

class Animal {
  constructor(public name: string) {}

  move(distance: number = 0): void {
    console.log(`${this.name} moved ${distance}m`);
  }
}

class Dog extends Animal {
  constructor(name: string, public breed: string) {
    super(name);  // فراخوانی constructor والد
  }

  bark(): void {
    console.log(`${this.name} is barking`);
  }

  move(distance: number = 5): void {
    super.move(distance);  // فراخوانی متد والد
    console.log("Running like a dog!");
  }
}

const dog = new Dog("Rex", "Labrador");
dog.move(10);

سه نکته‌ی مهم که در پروژه‌ها زیاد به‌کارم آمده:

  • super() اجباری است: اگر از یک class ارث‌بری می‌کنید، باید در constructor فرزند، قبل از هر دسترسی به this، super() را فراخوانی کنید. فراموش کردن این، خطای runtime در TS می‌دهد.
  • ترتیب پارامترها: در مثال بالا، پارامتر name در والد تعریف شده و پارامتر breed در فرزند. اگر ترتیب constructor والد را عوض کنید، فرزندها نیاز به به‌روزرسانی دارند. این یک نقطه‌ی شکست است — به‌خصوص در کتابخانه‌های عمومی.
  • override مطمئن: با نسخه‌های جدید TS، می‌توانید از modifier override استفاده کنید که کامپایلر را مجبور می‌کند بررسی کند متد override شده واقعاً در والد وجود دارد یا نه. این یک لایه‌ی محافظت اضافه است که در پروژه‌های بزرگ از باگ‌های ناشی از typo در نام متد جلوگیری می‌کند.

عمق ارث‌بری: قاتل خاموش درخت کلاس‌ها

در پروژه‌ای که بازبینی کردم، درخت ارث‌بری کلاس‌ها به شش سطح رسیده بود. تغییر در یک متد پایه، در ده‌ها کلاس فرزند اثر می‌گذاشت و تحلیل تأثیر، تقریباً غیرممکن بود. تجربه‌ی من: عمق ارث‌بری را زیر سه سطح نگه دارید. اگر نیاز به پیچیدگی بیشتر دارید، به‌جای ارث‌بری از ترکیب (composition) استفاده کنید. مطالعه‌ی این موضوع در چارچوب معماری در شی‌گرایی در تایپ اسکریپت آمده است.

Abstract Class و Abstract Method

abstract کلاس‌ها یک مفهوم منحصربه‌فرد TS هستند که در جاوااسکریپت خالص وجود ندارند:

abstract class Shape {
  abstract area(): number;
  abstract perimeter(): number;

  describe(): string {
    return `Area: ${this.area()}, Perimeter: ${this.perimeter()}`;
  }
}

class Rectangle extends Shape {
  constructor(private width: number, private height: number) {
    super();
  }

  area(): number {
    return this.width * this.height;
  }

  perimeter(): number {
    return 2 * (this.width + this.height);
  }
}

const rect = new Rectangle(10, 5);
console.log(rect.describe());

// خطای کامپایل: نمی‌توان از class abstract نمونه ساخت
// const shape = new Shape();

سه نکته‌ی مهم در مورد abstract:

  • abstract class قابل نمونه‌سازی نیست: شما نمی‌توانید new Shape() بنویسید. هدف abstract، ارائه‌ی یک قالب است که فرزندها آن را کامل کنند.
  • abstract method اجباری است: هر کلاس فرزندی که abstract نیست، باید تمام abstract methodهای والد را پیاده‌سازی کند. اگر این کار را نکند، کامپایلر خطا می‌دهد.
  • ترکیب abstract با پیاده‌سازی: یک کلاس abstract می‌تواند هم abstract method داشته باشد و هم متدهای پیاده‌سازی‌شده. این انعطاف، آن را به یک ابزار قدرتمند برای الگوهای Template Method تبدیل می‌کند.

در پروژه‌ای که یک سیستم پرداخت داشتیم، از abstract class برای تعریف قالب Gateway استفاده کردیم. هر درگاه پرداخت (زرین‌پال، آیدی‌پی، ملت) با ارث‌بری از این abstract class، رفتار اختصاصی خودش را پیاده‌سازی می‌کرد اما ساختار کلی یکسان باقی می‌ماند.

class با implements: تفاوت بنیادی با extends

در TS، یک class می‌تواند یک یا چند interface را پیاده‌سازی کند (implements):

interface Serializable {
  serialize(): string;
}

interface Comparable<T> {
  compareTo(other: T): number;
}

class Product implements Serializable, Comparable<Product> {
  constructor(public id: number, public name: string, public price: number) {}

  serialize(): string {
    return JSON.stringify({ id: this.id, name: this.name, price: this.price });
  }

  compareTo(other: Product): number {
    return this.price - other.price;
  }
}

تفاوت بنیادی extends و implements در یک جدول:

معیارextendsimplements
روابطارث‌بری رفتارتعهد به قرارداد
تعدادفقط یک کلاس والدچند interface
پیاده‌سازیبه ارث می‌رساندباید پیاده‌سازی کنید
کاربرد اصلیتوسعه‌ی موجودیتپیاده‌سازی چند قرارداد
در runtimeزنجیره‌ی prototypeهیچ اثری ندارد

قاعده‌ی من در پروژه‌ها: از extends فقط زمانی استفاده کنید که رابطه‌ی «is-a» واقعاً معنادار باشد. از implements برای پیاده‌سازی قراردادهای عمومی. مثلاً یک PostRepository یک Repository<Post> است (implements)، اما یک PostRepository یک SqlRepository نیست مگر اینکه پیاده‌سازی Sql با تمام جزئیاتش بخواهد به ارث برسد.

Static Members و کاربردهای واقعی

اعضای static متعلق به خود کلاس هستند، نه به نمونه‌ها:

class User {
  static count = 0;
  static readonly MAX_AGE = 120;

  constructor(public name: string, public age: number) {
    if (age > User.MAX_AGE) {
      throw new Error("Invalid age");
    }
    User.count++;
  }

  static createGuest(): User {
    return new User("Guest", 0);
  }
}

const user1 = new User("Ali", 30);
const user2 = new User("Sara", 25);
console.log(User.count);          // 2
const guest = User.createGuest();

کاربردهای واقعی static در پروژه‌ها:

  • Factory Methods: متدهای static برای ساخت نمونه‌های خاص. مثل User.createGuest() که در مثال بالا دیدیم. این الگو در پروژه‌هایی که ساختار پیچیده دارند، مقدار زیادی از کد تکرارشده را حذف می‌کند.
  • Counters و آمار: نگه‌داشتن شمارنده‌ی نمونه‌ها یا آمار سراسری. در پروژه‌های داشبوردی که تعداد رکوردهای پردازش‌شده را نگه می‌داشتیم، از static استفاده می‌کردیم.
  • ثابت‌ها: نگه‌داشتن ثابت‌های مربوط به کلاس. مثل User.MAX_AGE.
  • Utility Methods: توابعی که به نمونه وابسته نیستند. مثل مقایسه‌ی دو نمونه، تبدیل از فرمت خارجی به نمونه، و الگوهای مشابه.

یک هشدار مهم از تجربه: static property در TS به‌عنوان یک متغیر global رفتار می‌کند. اگر در پروژه‌ای از static برای نگه‌داشتن state استفاده کنید، ممکن است در تست‌های واحد با مشکل مواجه شوید چون این state بین تست‌ها باقی می‌ماند. راه‌حل: در هر تست، static را از نو تنظیم کنید.

Getter و Setter در TS

Getter و Setter در TS مشابه جاوااسکریپت هستند اما با تایپ‌چکینگ:

class Temperature {
  private _celsius: number = 0;

  get celsius(): number {
    return this._celsius;
  }

  set celsius(value: number) {
    if (value < -273.15) {
      throw new Error("Temperature below absolute zero");
    }
    this._celsius = value;
  }

  get fahrenheit(): number {
    return this._celsius * 9 / 5 + 32;
  }

  set fahrenheit(value: number) {
    this.celsius = (value - 32) * 5 / 9;
  }
}

const temp = new Temperature();
temp.celsius = 25;
console.log(temp.fahrenheit);  // 77
temp.fahrenheit = 100;
console.log(temp.celsius);     // 37.78

نکات مهم درباره‌ی getter/setter:

  • امکان منطق در setter: می‌توانید در setter اعتبارسنجی، تبدیل یا هر منطق دیگری انجام دهید. این یک لایه‌ی محافظت است.
  • نیاز به فیلد خصوصی: معمولاً getter/setter روی یک فیلد private کار می‌کنند که با _ یا روش‌های دیگر مشخص می‌شوند.
  • دسترسی مانند فیلد عمومی: از بیرون، با temp.celsius کار می‌کنید — نه temp.getCelsius(). این سینتکس تمیزتری است.
  • مراقب منطق سنگین: اگر getter منطق سنگینی داشته باشد، debug کردن دشوار می‌شود چون فراخوانی آن نامرئی است. قاعده‌ی من: getter و setter فقط برای تبدیل و اعتبارسنجی ساده.

class یا interface: تصمیم درست

یکی از پرتکرارترین سؤال‌ها در تیم‌ها: «برای این داده، class بسازم یا interface؟» پاسخ دقیق:

سناریوclassinterface
داده‌های ساده (DTO، State)غیرضروریمناسب
با متد و رفتارمناسبغیرضروری
نیاز به نمونه‌سازی با newالزامیغیرممکن
کپسوله‌سازی منطقمناسبغیرممکن
قرارداد برای چند پیاده‌سازیغیرمناسبمناسب
State داخلیمناسبغیرممکن

قاعده‌ی عملی من در پروژه‌ها: اگر ساختار داده‌ای فقط «شکل» است، از interface استفاده کنید. اگر «رفتار» دارد، class. این تفکیک ساده، ۸۰٪ تصمیم‌ها را سریع می‌کند. مطالعه‌ی بیشتر در اینترفیس در تایپ اسکریپت.

الگوهای واقعی در پروژه‌ها

سه الگویی که در پروژه‌های خودم بیشترین استفاده را داشته‌اند:

  1. Repository Pattern با abstract class: در یک پروژه‌ی Node.js، یک BaseRepository<T> به‌عنوان abstract class تعریف کردیم که شامل عملیات CRUD پایه بود. هر Repository اختصاصی (User، Product، Order)، با ارث‌بری از این کلاس پایه، فقط بخش‌های اختصاصی خودش را پیاده‌سازی می‌کرد. این الگو حدود ۶۰٪ از کد تکراری در لایه‌ی دسترسی به داده را حذف کرد.
  2. Value Objects با readonly: در یک پروژه‌ی مالی، از class برای Value Objectها (Money، Email، PhoneNumber) استفاده کردیم. تمام فیلدها readonly بودند و اعتبارسنجی در constructor انجام می‌شد. نتیجه: باگ‌های مربوط به داده‌ی نامعتبر در بالادست گرفته می‌شد و منطق دامنه، همیشه با داده‌ی معتبر کار می‌کرد. مطالعه‌ی موازی در شی‌گرایی در تایپ اسکریپت.
  3. Factory Method برای ساخت نمونه‌های پیچیده: در پروژه‌ای که یک سیستم مدیریت محتوا داشتیم، کلاس Post با یک متد static Post.fromApiResponse(data) داشتیم که داده‌ی خام API را به نمونه‌ی Post تبدیل می‌کرد. این الگو منطق تبدیل را متمرکز می‌کرد و در جاهای مختلف فقط فراخوانی ساده بود. مطالعه‌ی موازی در تایپ اسکریپت با Node.js.

اشتباهاتی که در پروژه‌ها دیدم

  • منطق سنگین در constructor: در پروژه‌ای، یک constructor بیش از ۸۰ خط منطق داشت. این باعث می‌شد تست کلاس دشوار، debug سخت، و خطاها غیرقابل ردیابی باشند. راه‌حل: منطق را به متدهای جداگانه منتقل کنید و constructor را ساده نگه دارید.
  • استفاده از class برای هر چیزی: در پروژه‌ای که با React کار می‌کردیم، توسعه‌دهنده‌ها برای هر داده‌ی ساده یک class می‌ساختند. اما در React، State معمولاً با interface یا type مدیریت می‌شود. استفاده از class در این سناریو، پیچیدگی اضافه می‌کند بدون سود محسوس.
  • نادیده گرفتن access modifier: در پروژه‌ای دیدم که تمام فیلدهای class بدون modifier نوشته شده بودند. در نتیجه همه‌ی مصرف‌کننده‌ها به همه‌ی فیلدها دسترسی داشتند و کپسوله‌سازی معنایی نداشت. همیشه modifier صریح بنویسید.
  • عمق ارث‌بری بیش‌ازحد: درخت ارث‌بری پنج یا شش سطحی، تحلیل تأثیر تغییرات را تقریباً غیرممکن می‌کند. قاعده‌ی من: حداکثر سه سطح عمق ارث‌بری.
  • استفاده از private به‌عنوان محافظت واقعی: در runtime، private در TS هیچ محافظتی ندارد. اگر به محافظت واقعی نیاز دارید، از #private استفاده کنید.
  • static به‌عنوان Global State: استفاده‌ی بی‌رویه از static برای نگه‌داشتن state سراسری، در تست‌ها مشکل ایجاد می‌کند و باعث وابستگی‌های پنهان می‌شود. static را فقط برای ثابت‌ها و Utility Methods نگه دارید.
  • عدم استفاده از override modifier: در نسخه‌های جدید TS، modifier override یک محافظت عالی است. اگر از آن استفاده نکنید، typo در نام متد override، به‌جای خطای کامپایل، یک متد جدید می‌سازد که هیچ‌وقت فراخوانی نمی‌شود.
  • Getter و Setter با منطق سنگین: getter و setter باید ساده و سریع باشند. اگر منطق سنگینی دارند، بهتر است به‌عنوان متد جداگانه تعریف شوند. تجربه‌ام: هر getter و setter با بیش از سه خط منطق، بازبینی مجدد لازم دارد.

بخشی از این اشتباهات در خطاهای رایج تایپ اسکریپت و اشتباهات رایج توسعه هم آمده است.

لایه‌ای زیر پوست کلاس‌ها

اینجا وارد لایه‌ای می‌شوم که در پروژه‌های معمولی به آن نگاه نمی‌شود اما برای مهندسان پلتفرم و توسعه‌دهنده‌های ارشد اهمیت دارد. آنچه کامپایلر TypeScript و موتور اجرای JS با classهای شما می‌کنند، در پنج مفهوم خلاصه می‌شود:

  1. Compilation of Class به JavaScript Prototype: در زمان کامپایل، class در TS به یک تابع سازنده (constructor function) با prototype تبدیل می‌شود. access modifierها و typeها کاملاً حذف می‌شوند. یعنی در runtime، private و public هیچ تفاوتی ندارند — همه یکسان در prototype قرار می‌گیرند. تنها استثنا #private است که در runtime هم محافظت واقعی دارد. این تفاوت باعث می‌شود که اگر امنیت runtime مهم است، private به‌تنهایی کافی نباشد. مطالعه‌ی موازی این لایه در مفاهیم پیشرفته جاوااسکریپت آمده است.
  2. Prototype Chain و Method Resolution: وقتی یک کلاس از کلاس دیگری ارث‌بری می‌کند، JS زنجیره‌ی prototype می‌سازد. جستجوی متد از نمونه شروع می‌شود، به prototype کلاس فرزند می‌رود، و اگر پیدا نشد، به prototype کلاس والد. این رفتار توضیح می‌دهد چرا override کردن متد در عمق زیاد، هزینه‌ی جستجو ایجاد می‌کند. برای مطالعه‌ی عمیق این مکانیزم، شی گرایی در جاوااسکریپت را ببینید.
  3. Class Field Initialization Order: در TS، فیلدهای کلاس به ترتیب تعریف اولیه‌سازی می‌شوند، سپس constructor اجرا می‌شود. ترتیب دقیق: ابتدا فیلدهای فرزند، سپس super()، سپس فیلدهای والد. این ترتیب می‌تواند در کلاس‌های پیچیده باعث رفتار غیرمنتظره شود — مثلاً اگر فیلدی در فرزند استفاده شود اما مقدارش در والد تعیین شده باشد. مطالعه‌ی این لایه در آموزش جاوااسکریپت از صفر آمده است.
  4. Abstract Class و Interaction با کامپایلر: abstract در TS فقط در کامپایل بررسی می‌شود. در runtime، abstract class یک کلاس معمولی است که می‌توان آن را نمونه‌سازی کرد (اگرچه TS جلوی این کار را می‌گیرد). این یعنی در کد JS نهایی، هیچ محافظتی از abstract وجود ندارد. برای استفاده‌ی صحیح، به کامپایلر TS اعتماد کنید و از کامپایل مستقیم به JS بدون این مرحله اجتناب کنید. مطالعه‌ی موازی در تنظیمات tsconfig آمده است.
  5. Interaction با Tree Shaking و Dead Code Elimination: در bundlerهای مدرن مثل Vite و Webpack، کدهایی که استفاده نمی‌شوند حذف می‌شوند. اما class‌ها در این فرآیند با احتیاط بیشتری حذف می‌شوند چون معمولاً به‌عنوان side effect احتمالی در نظر گرفته می‌شوند. اگر می‌خواهید bundle حجم کمتری داشته باشد، از تابع و interface به‌جای class برای داده‌های ساده استفاده کنید. مطالعه‌ی این موضوع در چارچوب بهینه‌سازی در بهینه سازی جاوااسکریپت و بهینه‌سازی سرعت سایت آمده است.

یک تجربه‌ی واقعی از پروژه‌ای که با ترتیب initialization روبرو شدیم: در یک کلاس فرزند، فیلدی به‌عنوان پیش‌فرض داشتیم و متدی که در constructor والد فراخوانی می‌شد، از آن فیلد استفاده می‌کرد. اما به‌دلیل ترتیب initialization، فیلد در زمان فراخوانی هنوز مقدار پیش‌فرض خودش را نگرفته بود. این باگ در debug معمولی سخت پیدا می‌شد اما با یک نگاه دقیق به ترتیب اجرای constructorها حل شد. راه‌حل نهایی: بازنویسی متد والد برای احتیاط و انتقال مقداردهی به بعد از super() در فرزند.

اگر روی پروژه‌های وردپرسی هستید و می‌خواهید این لایه‌ها را در development pipeline خود اعمال کنید، پیشنهاد می‌کنم ابتدا به توسعه وردپرس از صفر نگاهی بیندازید. برای مطالعه‌ی موازی با استانداردها و معماری، استانداردهای HTML و CSS و CSS مدرن از Flexbox تا Grid دید وسیع‌تری می‌دهند. برای درک این لایه در چارچوب فریم‌ورک‌ها، تایپ اسکریپت با React و تایپ اسکریپت با Node.js منابع کلیدی هستند. اگر روی موضوع تست و کیفیت کد متمرکز هستید، گیت در وردپرس و ابزارهای CI/CD را هم ببینید. برای مطالعه‌ی ماژول‌ها که با کلاس‌ها در ساختار پروژه در هم تنیده‌اند، ماژول ها در تایپ اسکریپت را از دست ندهید.

کلاس در TypeScript یک قرارداد رفتاری است که در لایه‌ی کامپایل، یک لایه‌ی محافظت اضافه می‌کند اما در لایه‌ی runtime، فقط یک تابع سازنده است. شناخت این تفاوت، تفاوت بین استفاده‌ی آگاهانه و استفاده‌ی توهمی از class است.

ایستگاه پایانی این مسیر

کلاس در TypeScript را می‌توان در یک جمله خلاصه کرد: «ابزاری برای مدل‌سازی موجودیت‌های دارای رفتار و حالت، با امکانات تایپ‌چکینگ که در جاوااسکریپت خالص وجود ندارند.» سه درس که از این مسیر با خودم بردم:

  1. class را برای رفتار به‌کار ببرید، نه برای داده. اگر داده‌ی شما فقط «شکل» دارد، از interface یا type استفاده کنید. class برای جایی است که موجودیت شما واقعاً رفتار دارد و می‌خواهید آن رفتار را با کپسوله‌سازی همراه کنید. این تفکیک ساده، در پروژه‌های بزرگ، حجم کد و پیچیدگی را به‌طور محسوس کاهش می‌دهد.
  2. access modifierها را جدی بگیرید. public، private و protected فقط سینتکس نیستند؛ یک قرارداد معماری هستند که به مصرف‌کننده می‌گویند چه چیزی بخشی از API است و چه چیزی جزئیات پیاده‌سازی. حتی اگر runtime محافظت واقعی نداشته باشد، در لایه‌ی توسعه و بازبینی کد، همین تفکیک، پروژه را منظم نگه می‌دارد.
  3. عمق ارث‌بری را کنترل کنید. درخت ارث‌بری بیش از سه سطح، تحلیل تأثیر تغییرات را دشوار و شکننده می‌کند. اگر نیاز به پیچیدگی بیشتر دارید، به‌جای ارث‌بری از ترکیب (composition) استفاده کنید. این یک تصمیم ساده است که در طول عمر پروژه، تفاوت محسوسی در سرعت توسعه می‌سازد.

مسیر یادگیری فرانت‌اند با این نوشته تمام نمی‌شود. اگر می‌خواهید مرحله‌ی بعدی را بردارید، آموزش تایپ اسکریپت از صفر، اینترفیس در تایپ اسکریپت و جنریک در تایپ اسکریپت سه قدم منطقی بعدی هستند. اگر روی فریم‌ورک‌ها متمرکز هستید، تایپ اسکریپت با React، تایپ اسکریپت با Node.js و ماژول ها در تایپ اسکریپت دید وسیع‌تری می‌دهند. اگر هم به سمت خطاها و دیباگ می‌روید، خطاهای رایج تایپ اسکریپت و تنظیمات tsconfig منابع کلیدی هستند.

در تجربه‌ی خودم، طراحی درست کلاس‌ها همیشه در پروژه‌هایی دیده می‌شود که تیم قبل از نوشتن کد، درباره‌ی مسئولیت هر کلاس و مرزهای آن به توافق رسیده. اگر شما هم با موقعیتی روبرو شده‌اید که بازطراحی کلاس‌ها یک پروژه را از بدهی فنی نجات داده — یا برعکس، یک کلاس بی‌قاعده باعث ساعت‌ها دیباگ شده — آن داستان را برای ما تعریف کنید. برای کسی که امروز در حال طراحی معماری یک پروژه‌ی جدید است، این نوع روایت‌ها ارزش عملی بیشتری از هر راهنمای نظری دارند.