ข้ามไปยังเนื้อหา

Classes & Advanced OOP

เกือบทุกอย่างในคอร์สนี้เป็น type — construct ที่อยู่ตอน compile time แล้วถูก erase ทิ้ง แต่ class ต่างออกไป เป็นหนึ่งในไม่กี่ declaration ที่สร้างทั้ง type และ value ชื่อ class เป็น type ที่คุณเอาไป annotate ได้ และในเวลาเดียวกันก็เป็น runtime constructor ที่เรียกด้วย new ได้

class User {
name: string;
constructor(name: string) {
this.name = name;
}
}
const u: User = new User("Ada"); // `User` as a TYPE (annotation) and a VALUE (constructor)

ธรรมชาติสองด้านนี้คือเหตุผลที่ class รู้สึกพิเศษ และเป็นเหตุผลที่ feature ของตัวเองแบ่งเป็นสองกลุ่ม: กลุ่มที่ compiler เช็คแล้ว erase (เช่น private) กับกลุ่มที่รอดไปถึง JavaScript ที่ emit ออกมา (เช่น #private field)

บทเรียนสิ่งที่คุณจะได้เรียนรู้
Classes, deepaccess modifier, readonly, parameter property, static, abstract, implements
this types & polymorphismpolymorphic this, this parameter และ pitfall เรื่อง binding ใน type system
Decoratorsdecorator แบบ stage-3 สมัยใหม่ — class, method, field และ factory
Mixins & compositionสร้าง behavior จาก class factory ที่ compose ได้ แทน hierarchy ลึก ๆ

TypeScript รองรับ extends inheritance แบบคลาสสิก แต่โมดูลนี้เน้นไปที่ feature ที่ community ใช้จริงใน code สมัยใหม่: composition over inheritance class hierarchy ที่ลึกมัก brittle ส่วน this type, decorator และ mixin ช่วยให้สร้าง behavior ที่ยืดหยุ่นและ reuse ได้โดยไม่ต้องพึ่ง inheritance

flowchart TB
  cls["class declaration"] --> typeworld["TYPE world
(erased): private, protected,
implements, abstract"]
  cls --> valueworld["VALUE world
(runtime): #private, static,
the constructor, methods"]
feature ของ class มีสองแบบ

ทุก type อื่นในคอร์สนี้ — object type, interface, function type — ถูกเทียบแบบ structural: สอง type เข้ากันได้ถ้า shape ตรงกัน ไม่ว่าจะชื่ออะไรหรือประกาศไว้ที่ไหน class ก็ตามกฎเดียวกันสำหรับ public member แต่พอเพิ่ม private (หรือ protected) member เข้ามา class จะเลิกเป็น structural ล้วน ๆ กลายเป็น nominal-ish: สอง class ที่มี shape เหมือนกันเป๊ะ รวมถึง private field ชื่อเดียวกัน ก็ยัง assign เข้าหากันไม่ได้

// @expect-error TS2345
class ApiClient {
private token: string;
constructor(token: string) {
this.token = token;
}
fetch(path: string) {
return `${path}?token=${this.token}`;
}
}
function useClient(client: ApiClient) {
return client.fetch("/users");
}
// A test double with the EXACT same shape, field for field, method for method —
// structurally this should be a perfect match.
class FakeApiClient {
private token: string;
constructor() {
this.token = "fake";
}
fetch(path: string) {
return `${path}?token=${this.token}`;
}
}
useClient(new FakeApiClient()); // ❌ still rejected
error TS2345: Argument of type 'FakeApiClient' is not assignable to parameter of type 'ApiClient'.
Types have separate declarations of a private property 'token'.

ไม่มีอะไรใน shape ของ token ที่ต่างกันระหว่างสอง class เลย — ชื่อเดียวกัน type เดียวกัน visibility เดียวกัน แต่ TypeScript ยัง reject การ assign นี้ เพราะ private member แต่ละตัวถูก tag ด้วย class ที่ประกาศมัน และ ApiClient#token กับ FakeApiClient#token เป็นสอง tag ที่ต่างกัน แม้จะหน้าตาเหมือนกันบนกระดาษ นี่คือจุดเดียวที่ model structural ของ TypeScript โน้มไปทาง nominal typing — มีไว้เพื่อไม่ให้สอง class ที่ไม่เกี่ยวกันมา satisfy private contract ของกันและกันโดยบังเอิญ แค่เพราะบังเอิญประกาศ field ชื่อเดียวกัน

field แบบ #private (ECMAScript private field ของจริง ไม่ใช่แค่ keyword private) บังคับกฎเดียวกันเป๊ะ ด้วยเหตุผลเดียวกัน:

// @expect-error TS2345
class WalletA {
#balance = 0;
}
class WalletB {
#balance = 0;
}
function drain(w: WalletA) { return w; }
drain(new WalletB()); // ❌ different #balance, even though both look the same
error TS2345: Argument of type 'WalletB' is not assignable to parameter of type 'WalletA'.
Property '#balance' in type 'WalletB' refers to a different member that cannot be accessed from within type 'WalletA'.

เพราะ field #name ผูกกับ class ที่ประกาศมันเป๊ะ ๆ การเช็คว่า field นั้นมีอยู่บน object ที่ไม่รู้จัก (#name in obj) จึงทำงานเป็น brand check ที่เชื่อถือได้และปลอมไม่ได้ — ไม่มีอะไรนอก class จะเพิ่ม private slot นั้นเข้าไปได้เลย:

A #private field as an unforgeable brand

class Wallet {
#balance = 0;
static isWallet(obj: unknown): obj is Wallet {
return typeof obj === "object" && obj !== null && #balance in obj;
}
}
const w = new Wallet();
console.log(Wallet.isWallet(w)); // true -- has the real #balance slot
console.log(Wallet.isWallet({})); // false -- no such slot exists, and none could be faked
Open in TS Playground

กด Run: ได้ true แล้ว false ต่อให้ duck-type object ให้ หน้าตา เหมือน Wallet แค่ไหน — เพิ่ม property ที่ชื่อ balance ตรง ๆ หรือ public field อื่นใด — ก็ทำให้ #balance in obj เป็น true ไม่ได้ ชื่อที่มี # นำหน้าไม่ใช่แค่ถูกซ่อน มันไม่ใช่ string property key ที่ code อื่นจะสะกดถูกด้วยซ้ำ นี่คือเครื่องมือที่คมที่สุดที่ TypeScript (ผ่าน ECMAScript private field ของจริง) มีสำหรับเปลี่ยน structural type ให้เป็น nominal จริง ๆ โดยตั้งใจ

ถามคำถามเดียวเมื่อการ assign class ที่คุณคาดว่าจะทำงานได้กลับถูก reject: มีฝั่งไหนประกาศ private หรือ #private member หรือเปล่า? ถ้าไม่มี กฎ structural ปกติจากทุกบทที่ผ่านมาก็ใช้เหมือนเดิม — shape คือทุกอย่าง ถ้ามี TypeScript กำลังตั้งใจเป็น nominal สำหรับ member นั้น — ไม่สนว่า shape ตรงกันไหม สนแค่ว่าทั้งสองฝั่ง trace กลับไปที่ declaration เดียวกัน หรือเปล่า #private ไปไกลกว่า private อีก — การเช็ค #name in obj ให้ brand ที่ enforce ตอน runtime จริงและปลอมจากภายนอกไม่ได้ นี่จึงเป็นวิธี idiomatic ในการตอบว่า “นี่คือ instance ของ class ฉันจริงไหม” สำหรับ code ที่เกี่ยวกับ security หรือ identity ไม่ใช่แค่ความสะดวกตอน type-check

Featureประโยชน์ต้นทุน
public member (structural)ยืดหยุ่นสูงสุด — value ที่ shape ตรงกันใช้ได้หมด รวมถึง plain object และ test doubleไม่มีการป้องกัน shape ชนกันโดยบังเอิญระหว่าง type ที่ไม่เกี่ยวกัน
private memberบอก intent จับการ assign ข้าม class โดยไม่ตั้งใจ compile-time onlyerase ตอน emit — คนที่ตั้งใจจริงเข้าถึงผ่าน (obj as any).field ได้; สอง class ที่ shape เหมือนกันก็ยังไม่ match structurally
#private fieldprivacy จริงตอน runtime, brand check ปลอมไม่ได้ผ่าน #x in obj, รอดไปถึง JavaScript ที่ emitceremony เพิ่มนิดหน่อย (syntax #) และ — เหมือน private — ทำให้ class เป็น nominal-ish สำหรับ member นั้น
  • “TypeScript เป็น structural ดังนั้นสอง class ที่ shape เหมือนกันใช้แทนกันได้” จริงแค่กับ public member เท่านั้น พอ class ไหนประกาศ private หรือ #private member ที่ชื่อตรงกัน member นั้น — และการเทียบ class ทั้งคู่ — จะถูกเช็คแบบ nominal: ต้อง declaration เดียวกันหรือไม่ก็ไม่เอาเลย
  • private กับ #private ป้องกัน field เหมือนกัน แค่ syntax ต่างกัน private เป็น annotation compile-time-only ที่ erase ตอน emit อ่านได้ตอน runtime ผ่าน any ส่วน #private ถูก JavaScript engine เองบังคับ และ bypass ด้วย cast ไม่ได้เลย — บทถัดไปพิสูจน์ทั้งสองข้อด้วย output จริงที่ emit ออกมา
  • test double ต้อง implements หรือ match structural กับ class ถึงจะใช้แทนกันได้ ถ้า class จริงมี private/#private member ใด ๆ double ที่เขียนเอง shape “เหมือนกัน” จะถูก reject ตรง ๆ (เหมือน FakeApiClient ข้างบน) — วิธีแก้ปกติคือแยก interface ที่อธิบายแค่ public contract แล้วให้ code ที่ใช้พึ่ง interface นั้นแทน concrete class

test double และ mock ที่ AI สร้างเป็นจุดที่เจอบ่อย: model ที่ถูกขอให้ “เขียน fake ApiClient สำหรับ test” มักจะ copy field ของ class จริงมาตรง ๆ รวม private modifier ด้วย แล้วผลลัพธ์ก็ type-check ไม่ผ่านทุกที่ที่ class จริงถูกคาดหวัง

  • เห็น private หรือ # field ถูก copy เข้า class ที่ไม่เกี่ยวกัน “เพื่อความเท่าเทียม”? ถามว่าสอง class นี้ตั้งใจให้แทนกันได้จริงไหม ถ้าใช่ วิธีแก้เกือบทุกครั้งคือแยก interface ที่อธิบายแค่ public contract แล้ว type code ที่ใช้งานกับ interface นั้นแทน concrete class
  • เห็น (obj as any).privateField ใน code ที่ generate มา อ่าน internal ของ class จากข้างนอก? นั่นคือการเจาะทะลุการ erase ที่เป็น compile-time-only ของ private — มันทำงานได้ แต่ทำลาย point ทั้งหมดของการ mark field เป็น private ตั้งแต่แรก และพังเงียบ ๆ ถ้า field ถูกเปลี่ยนชื่อ

🐛 หา bug ในโค้ดนี้

class ApiClient {
private token: string;
constructor(token: string) {
this.token = token;
}
fetch(path: string) {
return `${path}?token=${this.token}`;
}
}
function useClient(client: ApiClient) {
return client.fetch("/users");
}
class FakeApiClient {
private token: string;
constructor() {
this.token = "fake";
}
fetch(path: string) {
return `${path}?token=${this.token}`;
}
}
useClient(new FakeApiClient());
เฉลย

หน้าตาเหมือน test double ที่เขียนเองอย่างสมเหตุสมผล — field เดียวกัน constructor shape เดียวกัน method เดียวกัน — และ model ที่ถูกขอให้ “ทำ fake ApiClient สำหรับ test” จะได้ผลลัพธ์แบบนี้เกือบเป๊ะ มัน compile ไม่ผ่าน: TS2345: Argument of type 'FakeApiClient' is not assignable to parameter of type 'ApiClient'. Types have separate declarations of a private property 'token'. เพราะ ApiClient ประกาศ private token TypeScript เทียบ member นี้แบบ nominal — private token ของ FakeApiClient เป็น declaration คนละตัว แม้จะหน้าตาเหมือนกัน วิธีแก้คือเลิกพึ่ง concrete class: แยก interface ApiClientLike { fetch(path: string): string } (ไม่มี private member เพราะ interface มีไม่ได้) type useClient กับ interface นั้น แล้วให้ทั้ง ApiClient และ FakeApiClient satisfy มันแบบ structural

อะไรทำให้ class พิเศษกว่า type alias หรือ interface?
feature ของ class อันไหนที่รอดไปถึง JavaScript ที่ emit ออกมา?
อันนี้ compile ผ่านไหม?
class ApiClient {
  private token: string;
  constructor(token: string) { this.token = token; }
}
class FakeApiClient {
  private token: string;
  constructor() { this.token = "fake"; }
}
function useClient(client: ApiClient) { /* ... */ }
useClient(new FakeApiClient());
อันนี้ print อะไร แล้วทำไม?
class Wallet {
  #balance = 0;
  static isWallet(obj: unknown): obj is Wallet {
    return typeof obj === "object" && obj !== null && #balance in obj;
  }
}
console.log(Wallet.isWallet({ balance: 0 }));
TypeScript OOP สมัยใหม่มักเลือกแนวทางออกแบบแบบไหน?
test double ที่ generate มา copy field ของ class จริงมาทั้งหมด รวม private member ด้วย เพื่อให้ใช้แทนที่ class จริงได้ วิธีแก้ที่ idiomatic เมื่อ TypeScript reject มันคืออะไร?