Structural Typing
shape ไม่ใช่ชื่อ
หัวข้อที่มีชื่อว่า “shape ไม่ใช่ชื่อ”หลายภาษา (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 — compatiblePoint กับ 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 fineuser มีทุกอย่างที่ Named ต้องการ (มี name) จึงเข้าได้ ส่วน age ที่เกินมามองไม่เห็นจากฝั่งที่ถือ object นี้ในฐานะ Named
flowchart LR
val["value:
{ name, age, email }"] -->|มีทุกอย่างที่
Named ต้องการ| target["target type:
Named { name }"]
target -->|assign ได้ ✅| ok["ยอมรับ"] ข้อยกเว้น: excess property check บน object literal
หัวข้อที่มีชื่อว่า “ข้อยกเว้น: excess property check บน object literal”มีอยู่จุดหนึ่งที่ TypeScript เข้มขึ้น เมื่อคุณ assign object literal ตรง ๆ จะ flag property ที่ target ไม่รู้จัก — เพราะ literal ที่มี key แปลก ๆ เกือบจะเป็น typo หรือความผิดพลาดเสมอ
// @expect-error TS2353interface 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 literalassign 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 ที่จะดูตัวอย่างจริงข้างล่างนี้
Class ก็เป็น structural เหมือนกัน — จนกว่า private จะเข้ามา
หัวข้อที่มีชื่อว่า “Class ก็เป็น structural เหมือนกัน — จนกว่า private จะเข้ามา”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 — compatibleconsole.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 TS2322class 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 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
Branded type แบบเป็นรูปธรรม
หัวข้อที่มีชื่อว่า “Branded type แบบเป็นรูปธรรม”ปัญหา “บางครั้งคุณอยากให้ต่างแบบ nominal” ข้างบนมีทางแก้มาตรฐานใน structural system และควรเห็นสักครั้งตรงนี้ แม้จะพูดแบบเต็ม ๆ ทีหลัง: intersect primitive กับ marker property ที่ไม่มีอยู่จริงตอน runtime
// @expect-error TS2345type 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 อย่างถูกต้อง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”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"]
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| แนวทาง | ข้อดี | ต้นทุน |
|---|---|---|
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 ผ่านตัวแปรถูกยอมรับโดยไม่บ่นอะไรเลย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”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 จับเองไม่ได้พอดี