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

this Types & Polymorphism

ภายใน class this ไม่ได้เป็นแค่ value — เป็น type ที่หมายถึง “type ของ instance ปัจจุบัน” และที่สำคัญคือใน subclass this หมายถึง subclass ไม่ใช่ class ที่ method นั้นถูกเขียนไว้ นี่คือ polymorphic this type และทำให้ fluent API ทำงานถูกต้องข้าม inheritance

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

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 ที่อาจไม่ได้รับจริง

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 TS2684
class 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-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

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

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();
// @run
class 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 construction
console.log(d.greet()); // this call DOES get "derived" -- last write wins after construction
// @run
class Base {
greet = () => "base";
constructor() {
console.log(this.greet());
}
}
class Derived extends Base {
override greet = () => "derived";
}
const d = new Derived();
console.log(d.greet());
base
derived

ระหว่าง 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

method ที่ override จริงเรียก base implementation ผ่าน super.method() ได้เสมอ arrow field ทำไม่ได้ — ไม่มี prototype slot ให้ super ไปหา มีแค่ instance property และ TypeScript reject ความพยายามนี้ด้วย diagnostic เฉพาะ:

// @expect-error TS2855
class 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());
Open in TS Playground

กด 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 ของมันเองรันแล้ว

ทุกวิธีแก้ในบทนี้ 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 ได้เหมือนเดิม เข้าถึงไม่ได้: TS2855 report เรื่องนี้ตรง ๆ เพราะไม่มี prototype slot รองรับ arrow field ให้ super resolve ไปหา

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 อยู่แล้ว

type this ภายใน method ของ class หมายถึงอะไร?
ทำไมต้อง return this (แทนชื่อ class) จาก builder method ที่ chain ได้?
โดย default (ไม่ประกาศ this parameter) จะเกิดอะไรขึ้น?
class Counter {
  count = 0;
  increment() { this.count++; }
}
const c = new Counter();
const fn = c.increment;
fn();
อันนี้ log อะไร แล้วทำไม?
class Base {
  greet = () => "base";
  constructor() { console.log(this.greet()); }
}
class Derived extends Base {
  override greet = () => "derived";
}
new Derived();
ทำไม super.greet() ถึงพังด้วย TS2855 เมื่อ greet เป็น arrow-function class field ทั้งใน base และ subclass?
class ที่ generate มาใช้ arrow-function field เป็น "hook" ที่ base class เรียกจาก constructor ของตัวเอง คาดว่า override ของ subclass ที่ derive มากที่สุดจะรัน (pattern template-method) เกิดอะไรขึ้นจริง แล้ววิธีแก้คืออะไร?