Classes & Advanced OOP
class อยู่ทั้งสองโลก
หัวข้อที่มีชื่อว่า “class อยู่ทั้งสองโลก”เกือบทุกอย่างในคอร์สนี้เป็น 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, deep | access modifier, readonly, parameter property, static, abstract, implements |
this types & polymorphism | polymorphic this, this parameter และ pitfall เรื่อง binding ใน type system |
| Decorators | decorator แบบ stage-3 สมัยใหม่ — class, method, field และ factory |
| Mixins & composition | สร้าง behavior จาก class factory ที่ compose ได้ แทน hierarchy ลึก ๆ |
พูดถึง inheritance สักหน่อย
หัวข้อที่มีชื่อว่า “พูดถึง inheritance สักหน่อย”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"]
structural จนกว่า private จะเข้ามาเกี่ยว
หัวข้อที่มีชื่อว่า “structural จนกว่า private จะเข้ามาเกี่ยว”ทุก 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 TS2345class 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 rejectederror 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 TS2345class WalletA { #balance = 0;}class WalletB { #balance = 0;}function drain(w: WalletA) { return w; }drain(new WalletB()); // ❌ different #balance, even though both look the sameerror 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'.#private เป็น brand check
หัวข้อที่มีชื่อว่า “#private เป็น brand check”เพราะ 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 slotconsole.log(Wallet.isWallet({})); // false -- no such slot exists, and none could be faked กด 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 จริง ๆ โดยตั้งใจ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ถามคำถามเดียวเมื่อการ 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 only | erase ตอน emit — คนที่ตั้งใจจริงเข้าถึงผ่าน (obj as any).field ได้; สอง class ที่ shape เหมือนกันก็ยังไม่ match structurally |
#private field | privacy จริงตอน runtime, brand check ปลอมไม่ได้ผ่าน #x in obj, รอดไปถึง JavaScript ที่ emit | ceremony เพิ่มนิดหน่อย (syntax #) และ — เหมือน private — ทำให้ class เป็น nominal-ish สำหรับ member นั้น |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “TypeScript เป็น structural ดังนั้นสอง class ที่ shape เหมือนกันใช้แทนกันได้” จริงแค่กับ public member เท่านั้น พอ class ไหนประกาศ
privateหรือ#privatemember ที่ชื่อตรงกัน 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/#privatemember ใด ๆ double ที่เขียนเอง shape “เหมือนกัน” จะถูก reject ตรง ๆ (เหมือนFakeApiClientข้างบน) — วิธีแก้ปกติคือแยกinterfaceที่อธิบายแค่ public contract แล้วให้ code ที่ใช้พึ่ง interface นั้นแทน concrete class
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”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