this Types & Polymorphism
this ก็เป็น type ด้วย
หัวข้อที่มีชื่อว่า “this ก็เป็น type ด้วย”ภายใน class this ไม่ได้เป็นแค่ value — เป็น type ที่หมายถึง “type ของ instance ปัจจุบัน” และที่สำคัญคือใน subclass this หมายถึง subclass ไม่ใช่ class ที่ method นั้นถูกเขียนไว้ นี่คือ polymorphic this type และทำให้ fluent API ทำงานถูกต้องข้าม inheritance
fluent chaining ด้วย polymorphic this
หัวข้อที่มีชื่อว่า “fluent chaining ด้วย polymorphic this”builder ที่ return this จากแต่ละ method ทำให้ call ต่อกันเป็น chain ได้ การ type return เป็น this (ไม่ใช่ชื่อ class) แปลว่า method ของ subclass ยัง chain ต่อได้
class QueryBuilder { private parts: string[] = []; where(cond: string): this { // return type is `this`, not QueryBuilder this.parts.push(cond); return this; }}
class MySQLBuilder extends QueryBuilder { limit(n: number): this { return this; }}
new MySQLBuilder() .where("a = 1") // returns MySQLBuilder (because of `this`), so... .limit(10); // ...limit() is still available. ✅ถ้า where ถูก type ให้ return QueryBuilder chain จะ “ลืม” ว่าเป็น MySQLBuilder และ .limit() จะ fail polymorphic this รักษา type จริงไว้ตลอด chain
this parameter
หัวข้อที่มีชื่อว่า “this parameter”function สามารถ declare parameter ตัวแรกปลอม ๆ ชื่อ this เพื่อ type ว่า this ต้องเป็นอะไรตอน function ถูกเรียก parameter นี้ถูก erase ตอน emit และไม่กระทบ argument list จริง มีไว้ type-check calling context ล้วน ๆ
interface Card { title: string; }
function render(this: Card) { return this.title.toUpperCase();}
// render(); // ❌ The 'this' context of type 'void' is not assignable...render.call({ title: "hi" }); // ✅ this is a Card hereอันนี้จับ bug ทั้งกลุ่มที่ method พึ่ง this ที่อาจไม่ได้รับจริง
pitfall เรื่องเสีย this ใน type
หัวข้อที่มีชื่อว่า “pitfall เรื่องเสีย this ใน type”footgun คลาสสิกของ JavaScript — ส่ง method เป็น callback แล้วเสีย this — มีเรื่องราวระดับ type ด้วย เมื่อคุณส่ง obj.method แบบ by reference this binding จะหลุด
class Counter { count = 0; increment() { this.count++; } // relies on `this`}
const c = new Counter();const fn = c.increment;fn(); // 💥 at runtime `this` is undefined — and the type system says nothingโดย default type system ไม่จับ อันนี้เลย: increment ไม่เคยประกาศว่า this ต้องเป็นอะไร ดังนั้น fn() type-check ผ่านโดยไม่มี diagnostic เลยแม้จะพังตอน runtime strictBindCallApply ไม่ได้เพิ่มเช็คนี้ให้ — มันแค่ทำให้ .call/.apply/.bind เองตรวจ argument this ของมันกับ this parameter ที่ method ประกาศไว้แล้ว ถ้าอยากได้ warning ตอน compile ต้องให้ method ประกาศ this ที่ต้องการเองตรง ๆ:
// @expect-error TS2684class Counter { count = 0; increment(this: Counter) { this.count++; }}
const c = new Counter();const fn = c.increment;fn();ตอนนี้ mismatch ถูกจับแล้ว: TS2684: The 'this' context of type 'void' is not assignable to method's 'this' of type 'Counter'. วิธีแก้แบบ idiomatic คือใช้ arrow-function field (increment = () => { ... } ซึ่ง bind this แบบ lexical) หรือ bind ที่จุดเรียก (c.increment.bind(c))
วิธีแก้ด้วย arrow field แล้วมันเสียอะไรจริง ๆ
หัวข้อที่มีชื่อว่า “วิธีแก้ด้วย arrow field แล้วมันเสียอะไรจริง ๆ”arrow-function field แก้ bug เสีย this ได้จริง — arrow function ไม่มี this เป็นของตัวเอง ดังนั้น this ข้างในมันถูก capture แบบ lexical จากจุดที่ field ถูก define ซึ่งก็คือ constructor เสมอ และ constructor ก็มี instance จริงผูกอยู่เสมอ แต่ “arrow field” ไม่ใช่แค่ syntax variant ของ “method” — มันเป็น class member คนละชนิดเลย มีตำแหน่งตอน runtime ต่างกัน และกฎต่างกันสำหรับสองสิ่งที่ class ให้ฟรีตามปกติ: การ override และ super
ถูก allocate ต่อ instance ไม่ใช่ต่อ prototype
หัวข้อที่มีชื่อว่า “ถูก allocate ต่อ instance ไม่ใช่ต่อ prototype”method ธรรมดาอยู่ครั้งเดียวบน prototype แชร์กันทุก instance arrow-function field เป็น field — มัน allocate closure ของตัวเองใหม่ใน constructor ของทุก instance เดี่ยว ๆ JavaScript ที่ emit ออกมาแสดงเรื่องนี้ชัดเจน:
class Counter { count = 0; increment = () => { this.count++; }; // arrow-function field}compile ด้วย tsc --target es2025 (node_modules/typescript/bin/tsc --ignoreConfig --target es2025 --module esnext counter.ts) emit ตรงตามที่เขียนเป๊ะ ข้างใน constructor (ที่ implicit):
"use strict";class Counter { count = 0; increment = () => { this.count++; }; // arrow-function field}ดูไม่มีพิษภัยจนกว่าจะเทียบกับ method จริง: method compile เป็น function เดียว ประกาศครั้งเดียวบน Counter.prototype ที่ทุก new Counter() แชร์กันโดย reference arrow field compile เป็น assignment ที่รัน ข้างใน constructor call ทุกครั้ง allocate closure object ใหม่ต่อ instance Counter หนึ่งพัน instance แชร์ method increment เดียวกัน; Counter หนึ่งพัน instance ที่มี arrow field increment allocate closure แยกกันหนึ่งพันตัว แต่ละตัว capture this ของตัวเอง สำหรับ class ที่ instantiate น้อย เรื่องนี้ไม่มีนัยสำคัญ สำหรับ class ที่ instantiate ในระดับ scale (node ใน tree ขนาดใหญ่ แถวใน virtualized list) มันคือ memory ต่อ instance ที่เพิ่มขึ้นจริงและวัดได้ — trade-off ที่ codebase ทำเพื่อความปลอดภัยของ this ที่ระดับ class field
ถูก shadow ได้ แต่ override จริง ๆ ไม่ได้
หัวข้อที่มีชื่อว่า “ถูก shadow ได้ แต่ override จริง ๆ ไม่ได้”override ถูกยอมรับทาง syntax บน arrow field เพราะมันเป็นแค่ field re-declaration และ checker ไม่ได้ห้าม keyword ตรงนั้น — แต่มันไม่ได้ให้ guarantee เดียวกับที่ method override ให้ method จริงอยู่บน prototype ดังนั้น base class ที่เรียก this.method() ใน constructor ของตัวเอง dispatch ไปยัง override ของ subclass จริง ๆ แม้แต่ตอนกำลัง construct:
class Base { greet() { return "base"; } constructor() { console.log(this.greet()); // real prototype method -- genuinely virtual/polymorphic }}class Derived extends Base { override greet() { return "derived"; }}new Derived();// @runclass Base { greet() { return "base"; } constructor() { console.log(this.greet()); }}class Derived extends Base { override greet() { return "derived"; }}new Derived();derivedทำแบบเดียวกันเป๊ะด้วย arrow field แล้ว base constructor จะเห็น field ของ base เอง — เพราะ field assignment ของ Derived ยังไม่รันตอนที่ body ของ super() (ก็คือ constructor ของ Base) กำลัง execute:
class Base { greet = () => "base"; constructor() { console.log(this.greet()); // calls Base's own arrow field -- not "virtual" }}class Derived extends Base { override greet = () => "derived";}const d = new Derived(); // logs during constructionconsole.log(d.greet()); // this call DOES get "derived" -- last write wins after construction// @runclass Base { greet = () => "base"; constructor() { console.log(this.greet()); }}class Derived extends Base { override greet = () => "derived";}const d = new Derived();console.log(d.greet());basederivedระหว่าง constructor ของ Base this.greet() print "base" — ตรงข้ามกับ version method จริงข้างบน ต่อเมื่อ construction เสร็จแล้วเท่านั้น เมื่อ field assignment ของ Derived เองรันและ shadow ของ Base แล้ว .greet() ถึง return "derived" นี่คือ [[Define]]-semantics ordering แบบเดียวกับที่ bug init order ของ class field ในบทก่อนสร้างขึ้นมา — arrow field ถูก “override” ได้แค่ในความหมายที่ว่า assignment หลังชนะสำหรับ call ที่เกิด หลัง constructor ทั้งคู่จบแล้ว ไม่ใช่ในความหมายของ virtual dispatch แบบ prototype จริงระหว่าง construction
เรียกผ่าน super ไม่ได้จริง ๆ
หัวข้อที่มีชื่อว่า “เรียกผ่าน super ไม่ได้จริง ๆ”method ที่ override จริงเรียก base implementation ผ่าน super.method() ได้เสมอ arrow field ทำไม่ได้ — ไม่มี prototype slot ให้ super ไปหา มีแค่ instance property และ TypeScript reject ความพยายามนี้ด้วย diagnostic เฉพาะ:
// @expect-error TS2855class Base { greet = () => "base";}class Derived extends Base { greet = () => super.greet() + "-derived";}error TS2855: Class field 'greet' defined by the parent class is not accessible in the child class via super.TS2855 มีไว้เฉพาะกรณีนี้ — ไม่ใช่ error “property ไม่มีอยู่” ทั่วไป แต่เป็น diagnostic ที่รู้เหตุผลตรง ๆ: greet เป็น class field บน parent ไม่ใช่ prototype method จึงไม่มีอะไรให้ super หาเจอ
Real method vs. arrow field: virtual dispatch during construction
class Base { greet = () => "base"; constructor() { console.log("during Base constructor:", this.greet()); }}class Derived extends Base { override greet = () => "derived";}const d = new Derived();console.log("after construction:", d.greet()); กด Run: during Base constructor: base แล้ว after construction: derived เปลี่ยน arrow field ทั้งคู่กลับเป็น method จริง (greet() { return "base"; } / override greet() { return "derived"; }) แล้วกด Run อีกครั้ง — บรรทัดแรกกลายเป็น derived ด้วย เพราะ method จริง dispatch แบบ virtual ตั้งแต่ subclass มีอยู่ ไม่ใช่แค่หลังจาก initializer ของมันเองรันแล้ว
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกวิธีแก้ในบทนี้ trade guarantee หนึ่งไปกับอีกอัน this parameter ไม่ trade อะไรเลย — เป็น compile-time check ล้วน ๆ ไม่มีต้นทุนตอน runtime ซึ่งเป็นเหตุผลที่ประกาศมันได้ฟรีและควรทำทุกครั้งที่ความถูกต้องของ method ขึ้นอยู่กับ this ของมันจริง ๆ วิธีแก้ arrow-field สำหรับ this ที่หายไป trade memory (closure หนึ่งต่อ instance แทนที่จะเป็น method หนึ่งต่อ class) และ trade สองสิ่งที่ method จริงให้ฟรี: virtual dispatch ระหว่างที่ base constructor กำลัง execute และการเข้าถึงได้ผ่าน super ไม่มี trade ไหนผิด — แต่การมองว่า arrow field เป็น “แค่ method ที่ syntax ต่างไป” คือความเข้าใจผิดที่สร้าง bug เซอร์ไพรส์ตรงจุดที่ base class เรียกมันจาก constructor หรือ subclass พยายาม extend มันด้วย super
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”วิธีแก้เรื่องเสีย this | ประโยชน์ | ต้นทุน |
|---|---|---|
this parameter (declare อย่างเดียว) | ไม่มีต้นทุนตอน runtime; เปลี่ยน runtime crash เงียบ ๆ ให้เป็น compile error ที่จุดเรียกที่ไม่ปลอดภัยตรงเป๊ะ | ไม่แก้อะไรด้วยตัวเอง — แค่ทำให้ bug ที่มีอยู่แล้วมองเห็นได้สำหรับ checker |
| arrow-function field | แก้ binding ได้จริง — this ถูกต้องเสมอไม่ว่า field จะถูกเรียกยังไง | closure ถูก allocate หนึ่งต่อ instance (ไม่แชร์บน prototype); เข้าถึงผ่าน super ไม่ได้; ถูก shadow ไม่ใช่ virtual dispatch ระหว่าง base constructor |
.bind() ที่จุดเรียก | ไม่ต้องเปลี่ยน class เอง | ลืมง่ายที่จุดใดจุดหนึ่งในหลายจุดที่เรียกใช้; ยังเป็น method บน prototype เหมือนเดิม ดังนั้นต้องทำวิธีแก้นี้ซ้ำทุกที่ที่ method ถูกส่งเป็น callback |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- arrow-function field เป็นแค่ method ที่เขียนด้วย syntax ต่างกัน มันเป็น field ที่ allocate ใหม่ต่อ instance ใน constructor ไม่ใช่ prototype method ที่แชร์กัน — สองอย่างนี้มีต้นทุน memory ต่างกันและ semantics การ override ต่างกัน ไม่ใช่แค่ syntax ต่างกัน
overrideบน arrow field ให้ guarantee เดียวกับที่overrideให้ method จริง มันถูกยอมรับทาง syntax และ shadow field ของ base จริงสำหรับ call ที่เกิดหลัง construction เสร็จแล้ว แต่ไม่ได้ให้ virtual dispatch ระหว่าง constructor ของ base class เอง — base ยังเห็น field ของตัวเองตรงจุดนั้น เพราะ field assignment ของ subclass ยังไม่รัน- เพราะ arrow field reassign ได้เหมือน field ทั่วไป
super.method()ควรเข้าถึง version ของ base ได้เหมือนเดิม เข้าถึงไม่ได้:TS2855report เรื่องนี้ตรง ๆ เพราะไม่มี prototype slot รองรับ arrow field ให้superresolve ไปหา
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”arrow-function class field เป็น pattern ที่ AI generate มาบ่อยมากสำหรับ “แก้” ปัญหา this แบบ React-style หรือ event-handler — มีเหตุผลดี มันมักจะทำงานได้จริง แต่มี mistake ต่อเนื่องสองอย่างที่พบบ่อยเมื่อ codebase มีหลายตัว:
- subclass พยายาม
super.method()arrow field ที่กำลัง override เพราะ code รอบข้างหน้าตาเหมือน method overriding ปกติเป๊ะ สังเกตTS2855โดยเฉพาะ — มันแปลว่า member ที่กำลังถูก extend เป็น field ไม่ใช่ method วิธีแก้เกือบทุกครั้งคือแปลงทั้ง base และ subclass member กลับเป็น method จริง (ถ้าthisไม่ได้ต้องการ lexical binding ตรงนั้นจริง ๆ) หรือ compose behavior ตรง ๆ แทนการเรียกผ่านsuper - constructor ของ base class เรียก arrow-field “hook” คาดว่า customization ของ subclass มีผลแล้ว สะท้อน pattern template-method แบบคลาสสิกที่ทำงานได้ดีกับ method จริง เพราะ arrow field ถูก assign หลัง
super()return เท่านั้น สิ่งนี้ทำให้ version ของ base เองรันเงียบ ๆ ระหว่าง construction — ปัญหา ordering เดียวกับ bug init order ของuseDefineForClassFieldsในบทก่อน แค่ถูก trigger ด้วย field-vs-field shadowing แทนที่จะเป็นObject.assignจากภายนอก
🐛 หา bug ในโค้ดนี้
class Widget {onMount = () => { console.log("Widget.onMount");};constructor() { this.onMount(); // "hook" pattern -- expects the most-derived onMount to run}}
class Dialog extends Widget {override onMount = () => { console.log("Dialog.onMount");};}
new Dialog();เฉลย
อันนี้ print Widget.onMount ไม่ใช่ Dialog.onMount — ซึ่งอ่านเหมือน bug สำหรับใครก็ตามที่คาดหวัง pattern template-method ที่ทำงานได้กับ method จริง constructor ของ Widget เรียก this.onMount() ขณะที่ super() (ตรงนี้คือ body ของ constructor ของ Widget เอง) ยังกำลัง run อยู่ ซึ่งเกิด ก่อน field assignment ของ Dialog เอง ตรงจุดนั้นเป๊ะ this.onMount ยังเป็น arrow field ของ Widget เอง — override onMount ของ Dialog ยังไม่ถูก assign เพราะ arrow field ถูก assign โดย constructor ของ subclass หลังจาก super() return เท่านั้น วิธีแก้คือทำให้ onMount เป็น method จริง (onMount() { ... } / override onMount() { ... }) ถ้า design ต้องการ virtual dispatch ระหว่าง construction จริง ๆ — method จริงอยู่บน prototype ตั้งแต่ class ถูก define ดังนั้น this.onMount() ใน constructor ของ Widget จะเห็น override ของ Dialog อยู่แล้ว