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

Structural Typing

หลายภาษา (Java, C#) ใช้ nominal typing: type สองตัวเข้ากันได้ก็ต่อเมื่อมีชื่อที่ประกาศร่วมกันหรือมีสาย inheritance เชื่อมกัน ส่วน TypeScript ใช้ structural typing: type สองตัวเข้ากันได้ถ้า shape ตรงกัน ไม่ว่าชื่อจะเป็นอะไร

interface Point { x: number; y: number; }
interface Coord { x: number; y: number; }
let p: Point = { x: 1, y: 2 };
let c: Coord = p; // ✅ different names, identical shape — compatible

Point กับ Coord ไม่เคยถูกประกาศว่าเกี่ยวข้องกัน แต่ TypeScript ไม่สนใจ value ที่มี x: number และ y: number เข้าได้กับทั้งคู่ บางทีเรียกว่า “duck typing” — ถ้าเดินเหมือน Point และร้องเหมือน Point value ตัวนั้นก็ คือ Point ในสายตาของ type checker

มี property เกินมาก็ไม่เป็นไร — เพราะเป็นความสัมพันธ์แบบ subset

หัวข้อที่มีชื่อว่า “มี property เกินมาก็ไม่เป็นไร — เพราะเป็นความสัมพันธ์แบบ subset”

value มี property เกิน มาได้ และยัง assign ให้ type ที่ขอน้อยกว่าได้ ข้อนี้ตามมาจาก model แบบเซตโดยตรง: เซตของ “object ที่มี x, y และ z” เป็น subset ของ “object ที่มี x, y”

interface Named { name: string; }
const user = { name: "Ada", age: 36 };
const n: Named = user; // ✅ user has name (plus extra) — that's fine

user มีทุกอย่างที่ Named ต้องการ (มี name) จึงเข้าได้ ส่วน age ที่เกินมามองไม่เห็นจากฝั่งที่ถือ object นี้ในฐานะ Named

flowchart LR
  val["value:
{ name, age, email }"] -->|มีทุกอย่างที่
Named ต้องการ| target["target type:
Named { name }"]
  target -->|assign ได้ ✅| ok["ยอมรับ"]
structural compatibility ว่าด้วย shape ที่จำเป็นต้องมี

มีอยู่จุดหนึ่งที่ TypeScript เข้มขึ้น เมื่อคุณ assign object literal ตรง ๆ จะ flag property ที่ target ไม่รู้จัก — เพราะ literal ที่มี key แปลก ๆ เกือบจะเป็น typo หรือความผิดพลาดเสมอ

// @expect-error TS2353
interface Options { width: number; }
const a: Options = { width: 10, height: 20 };
// ❌ Error: 'height' does not exist in type 'Options'.
const raw = { width: 10, height: 20 };
const b: Options = raw; // ✅ no error — not a fresh literal

assign object ตัวเดียวกัน ผ่านตัวแปรกลับผ่านได้ เพราะตอนนี้ใช้กฎ structural ปกติแล้ว (property เกินไม่เป็นไร) excess property check คือ safety net แคบ ๆ ที่ตั้งใจไว้จับ bug ยอดฮิต “พิมพ์ชื่อ option ผิด” — ไม่ใช่การเปลี่ยน model ที่อยู่เบื้องหลัง

structural typing คือเหตุผลที่ TypeScript ทำงานกับ object ธรรมดาได้ลื่น และเป็นเหตุผลที่คุณแทบไม่ต้องประกาศว่า type หนึ่ง “implements” อีก type และเป็นเหตุผลที่ library สองตัวที่มี Point type ของตัวเองใช้ด้วยกันได้เลย ต้นทุนคือ type ที่ไม่เกี่ยวกันแต่ shape เหมือนกันจะใช้แทนกันได้ — บางครั้งคุณ อยาก ให้ type ต่างกันแบบ nominal (UserId ที่ไม่ใช่แค่ string ตัวไหนก็ได้) ซึ่งคุณปลอมได้ด้วย branded type ที่จะดูตัวอย่างจริงข้างล่างนี้

structural compatibility ไม่ได้จำกัดแค่ interface/type — ใช้กับ class ด้วยเช่นกัน class สองตัวที่ไม่เคยถูกประกาศว่าเกี่ยวข้องกันยังใช้แทนกันได้ถ้า shape ตรงกัน:

class Point2D {
constructor(public x: number, public y: number) {}
}
class Coordinate {
constructor(public x: number, public y: number) {}
}
const p = new Point2D(1, 2);
const c: Coordinate = p; // ✅ unrelated classes, identical public shape — compatible
console.log(c.x, c.y);

Point2D กับ Coordinate ไม่มี inheritance ร่วมกัน ไม่มี interface ร่วมกัน มีแค่ field สาธารณะที่บังเอิญเหมือนกันเป๊ะ — และ TypeScript ยอมรับตัวหนึ่งแทนอีกตัวได้ทุกที่ที่ต้องการ เหมือน interface Point/Coord ก่อนหน้านี้เป๊ะ เรื่องนี้ทำให้คนที่มาจากภาษาที่เป็น nominal ประหลาดใจ เพราะที่นั่น class สองตัวที่ไม่เกี่ยวกันไม่มีทางใช้แทนกันได้ไม่ว่าจะมี field อะไรก็ตาม

มีอยู่อย่างเดียวที่ทำลายสิ่งนี้: member ที่เป็น private เพิ่มเข้าไปแม้จะชื่อและ type เหมือนกันทั้งสอง class ก็ทำให้ class เลิกเข้ากันได้ทันที:

private makes an otherwise-identical class incompatible

// @expect-error TS2322
class Point2D {
private secret = 1;
constructor(public x: number, public y: number) {}
}
class Coordinate {
private secret = 1;
constructor(public x: number, public y: number) {}
}
const p = new Point2D(1, 2);
const c: Coordinate = p; // ❌ TS2322 — each class's private member is its own, even with the same name
Open in TS Playground

Types have separate declarations of a private property 'secret' คือ message จริง TypeScript มีข้อยกเว้นแบบ nominal อยู่หนึ่งจุดใน structural check ของมัน: member ที่เป็น private (หรือ protected) จะถือว่าเหมือนกับ member ของอีก class ก็ต่อเมื่อมาจาก declaration เดียวกันเป๊ะ (ผ่าน inheritance) — ไม่ใช่แค่ชื่อกับ type ตรงกัน นี่คือจุดเดียวที่ model แบบเทียบ shape ในบทนี้เลิกใช้ และจะพูดถึงแบบเต็ม ๆ ตอนแนะนำ class

ปัญหา “บางครั้งคุณอยากให้ต่างแบบ nominal” ข้างบนมีทางแก้มาตรฐานใน structural system และควรเห็นสักครั้งตรงนี้ แม้จะพูดแบบเต็ม ๆ ทีหลัง: intersect primitive กับ marker property ที่ไม่มีอยู่จริงตอน runtime

// @expect-error TS2345
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
function getUser(id: UserId) {}
declare const orderId: OrderId;
getUser(orderId);
error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
Types of property '__brand' are incompatible.
Type '"OrderId"' is not assignable to type '"UserId"'.

ทั้ง UserId และ OrderId ข้างใต้คือ string ธรรมดา — ตอน runtime __brand ไม่มีอยู่จริงบนทั้งสอง value เลย มันเป็นแค่ marker ตอน compile-time เท่านั้น แต่เพราะ literal value ของ marker ต่างกัน ("UserId" กับ "OrderId") intersection ทั้งสองจึงไม่เหมือนกันแบบ structural อีกต่อไป compiler จึงถือว่าต่างกัน — ทั้งที่ string ธรรมดาจะไม่เข้ากับตัวไหนเลยถ้าไม่มีการ assert ตรง ๆ นี่คือทางหนีสำหรับช่องว่างที่ structural typing เปิดไว้พอดี: ใช้มันเมื่อ value สองตัวบังเอิญมี shape แบบ primitive เดียวกันแต่ต้องไม่ถูกแทนกันเด็ดขาด บท Typing APIs & Boundaries (Practical Mastery) พูดเรื่องการสร้างและ validate branded value อย่างถูกต้อง

Structural typing ถามคำถามเดียวเสมอ: “value นี้มีทุกอย่างที่ target shape ต้องการไหม” interface, type alias และ class ถูกตัดสินด้วยคำถามเดียวกันหมด — property เกินไม่เคยทำร้าย และ declaration ที่ไม่เกี่ยวกันแต่มี shape ที่ต้องการเหมือนกันก็เป็น type เดียวกันในสายตาของ assignability private คือข้อยกเว้นเดียวที่ตั้งใจ — มันทำให้ membership ขึ้นกับ ว่า property ถูกประกาศที่ไหน ไม่ใช่แค่หน้าตาเป็นยังไง นี่คือเหตุผลที่มันเป็นเครื่องมือเดียวที่เอาความต่างแบบ nominal กลับมาให้ class ได้ Branded type คือกลเม็ดเดียวกันแต่ไม่ต้องมี class เลย — property marker ที่มีหน้าที่เดียวคือทำให้ shape ที่เหมือนกันทุกอย่างพังการเช็ค “มีทุกอย่างที่ต้องการ” เมื่อเจอ value ที่ shape เดียวกันแต่ความหมายต่างกัน

flowchart TB
  q["Does the value have
every required property?"] -->|yes| ok["Assignable — regardless of
declared name or relationship"]
  q -->|"private member present"| nominal1["Membership now depends on
WHERE it was declared"]
  q -->|"branded marker property"| nominal2["Shapes differ by a
compile-time-only marker"]
Structural typing ถามคำถามเดียว มีสองเครื่องมือที่ขอออก
แนวทางข้อดีต้นทุน
Shape แบบ structural ธรรมดา (ไม่มี brand, ไม่มี private)ยืดหยุ่นสูงสุด — shape ที่ตรงกันใช้ได้จากแหล่งไหนก็ได้value สองตัวที่ความหมายต่างกันแต่ shape แบบ primitive เดียวกัน (UserId กับ OrderId ทั้งคู่เป็น string) ใช้แทนกันได้อย่างอิสระ
Member private บน classเอาความต่างแบบ nominal กลับมาให้ class โดยไม่ต้องมี type เพิ่มใช้ได้เฉพาะ class; shape แบบ interface/type ที่เปิดอยู่ไม่ได้รับการป้องกันแบบนี้
Branded type (string & { __brand: ... })ใช้ได้กับ primitive ไหนก็ได้ ไม่ใช่แค่ class; ไม่มี runtime costมี boilerplate ต่อ branded type หนึ่งตัว และต้องมีอะไรสักอย่างสร้าง branded value อย่างปลอดภัยจริง ๆ (พูดในบท Typing APIs & Boundaries)
  • Class ใน TypeScript เป็น nominal typing เหมือน Java หรือ C# ไม่จริง มันเป็น structural โดยค่าเริ่มต้น เหมือน interface เป๊ะ — class สองตัวที่ไม่เกี่ยวกันแต่ public shape เหมือนกันใช้แทนกันได้ มีแค่ member private/protected เท่านั้นที่เปลี่ยนสิ่งนี้
  • Branded type คือ runtime wrapper จริง เหมือน value object class ไม่จริง มันเป็นแค่นิยายระดับ compile-time เท่านั้น — __brand ไม่เคยมีอยู่จริงบน value จริง ความปลอดภัยอยู่ที่ระดับ type-checker 100% ไม่มีอะไรหยุด raw string ถูก force-cast เป็น branded type ผ่าน as ได้เลย
  • Excess property check แปลว่า TypeScript แอบเป็น nominal จริง ๆ มันเป็นแค่ typo-catcher แคบ ๆ ที่ตั้งใจไว้เฉพาะ object literal สด ๆ เท่านั้น — model เบื้องหลังยังเป็น “shape เข้าไหม” เหมือนเดิม นั่นคือเหตุผลที่ object เดียวกันเป๊ะที่ assign ผ่านตัวแปรถูกยอมรับโดยไม่บ่นอะไรเลย

Code ที่ generate มาซึ่งส่ง value ที่เป็น string/number ธรรมดาผ่านหลาย function คือจุดที่ความยืดหยุ่นของ structural typing กลายเป็นภาระบ่อยที่สุด เพราะไม่มีอะไรหยุด value ที่ผิดแต่ shape เหมือนกันไม่ให้ผ่านไปได้ ให้เช็ค:

  • identifier หลายตัวที่เป็น primitive type เดียวกันไหลผ่าน parameter ที่อยู่ใกล้กัน — userId: string, orderId: string, sessionToken: string — โดยไม่มี branding argument ที่พิมพ์ผิดลำดับ compile ผ่านสะอาดทุกครั้ง
  • การ lookup ที่ key ด้วย ID ชนิดหนึ่งแต่ได้รับตัวแปรที่ ชื่อ บ่งบอกว่าเป็น ID อีกชนิดหนึ่ง โดยมีแค่ชื่อตัวแปรเท่านั้นที่บอกความต่าง
  • class หรือ object shape สองอันที่ถูกใช้แทนกัน “เพราะ field ตรงกัน” ในบริบทที่สองสิ่งนั้นมีความหมายต่างกันจริง ๆ (Cart กับ Wishlist ที่บังเอิญมี items: Item[] เหมือนกัน)

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

interface User { id: string; name: string }
const users: Record<string, User> = { u1: { id: "u1", name: "Ada" } };
function getUserName(userId: string): string {
return users[userId].name;
}
const orderId = "o1"; // actually an order id, not a user id — both are just `string`
console.log(getUserName(orderId).toUpperCase());
เฉลย

โค้ดนี้ compile ผ่านโดยไม่มี error เลยแล้วพังตอน runtime getUserName รับ userId: string ธรรมดา และ orderId ก็เป็น string ธรรมดาเช่นกัน — เหมือนกันทั้งแบบ structural (จริง ๆ คือแบบ primitive เลยด้วยซ้ำ) compiler จึงไม่มีเหตุผลจะค้าน ทั้งที่ทั้งสองแทนความหมายที่ต่างกันโดยสิ้นเชิงใน domain users["o1"] เป็น undefined (ไม่มี user id นี้) ดังนั้น .name จึง throw TypeError: Cannot read properties of undefined (reading 'name') การ brand UserId กับ OrderId ให้เป็นคนละ type (stub ของบทนี้) จะทำให้เรื่องนี้กลายเป็น TS2345 ตอน compile time ที่จุดเรียก — ตรงกับ bug กลุ่มที่ความยืดหยุ่นของ structural typing จับเองไม่ได้พอดี

"structural typing" หมายความว่าอะไร?
value ที่มี property เกิน assign ให้ type ที่ต้องการน้อยกว่าได้ไหม?
ทำไม assign { width: 10, height: 20 } ตรง ๆ ให้ Options { width: number } ถึง error แต่ assign ผ่านตัวแปรกลับไม่ error?
เทคนิคใดให้ความต่างแบบ nominal ใน system ที่เป็น structural?
โค้ดนี้ compile ผ่านไหม?
class Point2D {
  constructor(public x: number, public y: number) {}
}
class Coordinate {
  constructor(public x: number, public y: number) {}
}

const p = new Point2D(1, 2);
const c: Coordinate = p;
การเพิ่ม private secret = 1; เหมือนกันทุกประการให้ทั้ง Point2D และ Coordinate ข้างบนเปลี่ยนผลลัพธ์ ทำไม?
ทำไม compile ไม่ผ่าน ทั้งที่ทั้งคู่สุดท้ายก็เป็น string?
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function getUser(id: UserId) {}
declare const orderId: OrderId;
getUser(orderId);