Make Illegal States Unrepresentable
ทำให้สถานะที่ผิดกฎ ไม่มีทางเขียนแทนได้ใน code
แนวคิดหลัก
หัวข้อที่มีชื่อว่า “แนวคิดหลัก”Make Illegal States Unrepresentable เป็นหลักการออกแบบที่ชี้นำให้คุณจำลอง domain ในลักษณะที่ทำให้สถานะที่ไม่ถูกต้องหรือไร้ความหมาย เป็นไปไม่ได้ที่จะแสดงออกใน code แทนที่จะพึ่งการตรวจสอบตอนรัน (runtime check) method validation หรือคอมเมนต์เพื่อป้องกันสถานะที่ผิด เป้าหมายคือใช้ type system เองเป็นกลไกบังคับ หาก compiler ปฏิเสธสถานะที่ผิด สถานะนั้นก็เกิดขึ้นตอนรันไม่ได้ — ไม่มีทางเลย ไม่ใช่แค่ “ไม่ควรเกิด”
วลีนี้ถูกให้เครดิตแก่ Yaron Minsky แห่ง Jane Street ผู้พูดถึงมันในการบรรยาย Effective ML (ราวปี 2010) ในบริบทของ type system ภาษา OCaml ต่อมา Scott Wlaschin ทำให้แนวคิดนี้เป็นที่รู้จักในวงกว้างผ่านบทความชุด “Designing with types” บนเว็บ F# for Fun and Profit และ Richard Feldman ก็นำแนวคิดเดียวกันไปเผยแพร่ในวงการ front-end ผ่านการบรรยาย “Making Impossible States Impossible” ในงาน elm-conf แม้แนวคิดจะถือกำเนิดในโลกของ functional programming และ type system ที่มี sum type แบบ first-class แต่ก็ใช้ได้ในทุกภาษาที่รองรับ type ที่แสดงออกได้ดีพอ รวมถึง C#, F#, TypeScript และ Kotlin — และล่าสุด C# เองก็กำลังเพิ่ม union types เป็น feature ภาษาโดยตรง (อยู่ระหว่าง preview) เพื่อรองรับสไตล์การออกแบบนี้ให้เป็นธรรมชาติยิ่งขึ้น
หัวใจของหลักการคือการมอง type ว่าเป็น “พื้นที่ของค่าที่เป็นไปได้” (state space) เมื่อ type หลวมเกินไป (เช่น ใช้ string, bool, หรือ field ที่ nullable อิสระจากกัน) พื้นที่นั้นจะกว้างกว่าพื้นที่ของสถานะที่ถูกต้องใน domain จริงเสมอ ช่องว่างระหว่างสองพื้นที่นี้คือที่ซ่อนของ bug งานออกแบบตาม type คือการบีบพื้นที่ของ type ให้เท่ากับพื้นที่ของสถานะที่ถูกต้องพอดี ไม่มากไม่น้อยกว่า
flowchart LR
subgraph Before
A1[Loose type] --> A2[Four possible states]
A2 --> A3[Two are invalid]
A3 --> A4[Runtime guards scattered everywhere]
end
subgraph After
B1[Constrained type] --> B2[Two possible states]
B2 --> B3[Both are valid by construction]
B3 --> B4[No guards needed]
end
ทำไมถึงสำคัญ
หัวข้อที่มีชื่อว่า “ทำไมถึงสำคัญ”- ข้อผิดพลาดตอนรันน้อยลง — สถานะที่แสดงแทนไม่ได้ ย่อมก่อ bug ไม่ได้ bug หลายประเภทถูกกำจัดไปตั้งแต่ตอน compile ไม่ต้องรอให้ QA หรือผู้ใช้เจอ
- code ป้องกันน้อยลง — method ที่รับค่าที่มี type ดีอยู่แล้วไม่ต้อง
if (x == null)หรือ validate input ที่เป็นไปไม่ได้อยู่แล้ว ลด noise และลดจำนวนเส้นทางที่ต้อง test - code อธิบายตัวเอง — ตัว type สื่อสารสถานะที่ถูกต้องเอง ไม่ต้องอ่านเอกสารหรือคอมเมนต์เพื่อรู้ว่า field ไหนใช้คู่กับ field ไหนได้
- compiler ช่วยตรวจ — switch expression และ pattern matching แบบครบถ้วน (exhaustive) รับประกันว่าทุกกรณีถูกจัดการ ไม่มี
defaultที่กลืนสถานะที่ไม่ได้จัดการไปเงียบ ๆ - refactor ปลอดภัยขึ้น — type เป็นแหล่งความจริงเดียวว่าอะไรถูกต้อง เมื่อ model domain เปลี่ยน ทุกที่ที่ยังใช้รูปร่างเดิมจะกลายเป็น compiler error ทันที แทนที่จะเป็น bug เงียบตอนรันที่รอวันปะทุ
- ลดต้นทุนของ bug — bug ที่จับได้ตอน compile มีต้นทุนถูกกว่า bug ที่จับได้ตอน production มาก การย้ายการตรวจจับให้เร็วที่สุดเท่าที่จะทำได้คือการลงทุนที่คุ้มค่า
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”ปัญหา: การแสดงสถานะที่ไม่ถูกต้อง
หัวข้อที่มีชื่อว่า “ปัญหา: การแสดงสถานะที่ไม่ถูกต้อง”เมื่อ class ใช้ type พื้นฐานหรือ field ที่มี type หลวม ๆ เก็บข้อมูลที่มีข้อจำกัดและความสัมพันธ์เฉพาะ มันจะเปิดช่องให้สร้าง object ที่ถูกต้องในเชิง type system แต่ไร้ความหมาย (หรือเป็นอันตราย) ในเชิง domain ลองพิจารณาการแจ้งเตือนทางอีเมลที่ส่งได้ทั้งไปยังที่อยู่เฉพาะหรือส่งถึงผู้ใช้ทุกคน แต่ไม่ใช่ทั้งสองอย่างพร้อมกัน:
public class EmailNotification{ public string? RecipientEmail { get; set; } public bool SendToAll { get; set; }}class นี้อนุญาตให้ RecipientEmail = null และ SendToAll = false พร้อมกัน — เป็นสถานะที่ไม่ได้ระบุผู้รับเลย และยังอนุญาตให้ตั้งค่าทั้งสองพร้อมกันได้ด้วย มีสี่สถานะที่เป็นไปได้ แต่ถูกต้องเพียงสองสถานะ ทุก method ที่ใช้ class นี้ต้องคอยป้องกันสองสถานะที่ผิดกฎด้วยตัวเอง — และมักมีสักจุดหนึ่งที่ลืมป้องกัน
ทางแก้: ฝังข้อจำกัดไว้ใน type
หัวข้อที่มีชื่อว่า “ทางแก้: ฝังข้อจำกัดไว้ใน type”ด้วยการออกแบบ type ใหม่ให้แสดงได้เฉพาะสถานะที่ถูกต้อง การป้องกัน (guard) จึงไม่จำเป็นอีกต่อไป:
public abstract class NotificationTarget{ public sealed class AllUsers : NotificationTarget { }
public sealed class SpecificRecipient : NotificationTarget { public EmailAddress Email { get; }
public SpecificRecipient(EmailAddress email) { Email = email; } }}ตอนนี้ NotificationTarget เป็นได้แค่ AllUsers หรือ SpecificRecipient เท่านั้น — ไม่มีอย่างอื่น สถานะ “ไม่มีผู้รับและไม่ได้ส่งถึงทุกคน” และ “ตั้งค่าทั้งสองพร้อมกัน” ไม่มีอยู่จริง method ใดที่รับ NotificationTarget จึงไม่ต้อง validate มัน เพียงจัดการสองกรณีที่ type system อนุญาตเท่านั้น รูปแบบนี้จับคู่ได้อย่างเป็นธรรมชาติกับ discriminated union (หรือ sum type) ซึ่งเป็น feature ระดับ first-class ใน F# และถูกจำลองใน C# ผ่านลำดับชั้น class แบบนี้ record หรือ (ตั้งแต่ C# 9 เป็นต้นมา) switch expression ที่ทำ pattern matching:
public string Describe(NotificationTarget target) => target switch{ NotificationTarget.AllUsers => "Sending to all users", NotificationTarget.SpecificRecipient r => $"Sending to {r.Email}", _ => throw new UnreachableException()};compiler บังคับให้จัดการทุกกรณี ไม่มี default ที่กลืนสถานะที่ไม่ได้จัดการไปเงียบ ๆ
ทำให้การเปลี่ยนสถานะชัดเจน
หัวข้อที่มีชื่อว่า “ทำให้การเปลี่ยนสถานะชัดเจน”นอกเหนือจากรูปร่างข้อมูล หลักการนี้ยังใช้กับ state machine และ object ที่มีวงจรชีวิต เมื่อ entity เคลื่อนผ่านสถานะต่าง ๆ การเปลี่ยนที่ผิดกฎควรเป็นสิ่งที่แสดงออกไม่ได้ ไม่ใช่แค่ระบุไว้ในเอกสาร:
// ก่อน: status เป็น string ค่าใดก็เป็นไปได้ทุกเมื่อpublic class Order{ public string Status { get; set; } = "Pending"; public DateTime? ShippedAt { get; set; } public string? TrackingNumber { get; set; }}ShippedAt และ TrackingNumber ควรมีก็ต่อเมื่อออเดอร์ถูกจัดส่งแล้วเท่านั้น แต่ไม่มีอะไรใน type ป้องกันไม่ให้มันถูกตั้งบนออเดอร์ที่ยัง pending (หรือหายไปในออเดอร์ที่จัดส่งแล้ว) หลังจากฝังการเปลี่ยนสถานะลงใน type:
// หลัง: Shipped ต้องมีวันจัดส่งและเลขติดตาม — compiler บังคับpublic abstract class OrderStatus{ public sealed class Pending : OrderStatus { }
public sealed class Shipped : OrderStatus { public DateTime ShippedAt { get; } public string TrackingNumber { get; }
public Shipped(DateTime shippedAt, string trackingNumber) { ShippedAt = shippedAt; TrackingNumber = trackingNumber; } }
public sealed class Cancelled : OrderStatus { public string Reason { get; } public Cancelled(string reason) => Reason = reason; }}สถานะ Shipped ต้อง มีวันจัดส่งและเลขติดตาม เพราะ constructor บังคับไว้ ส่วนสถานะ Pending ไม่สามารถ มีค่าเหล่านั้น เพราะ type ไม่มี field เหล่านั้น — compiler บังคับข้อจำกัดให้ ไม่ใช่ code review หรือเอกสารประกอบ
ในระดับ field หลักการเดียวกันนี้เป็นแรงผลักดันให้เกิด value object: string ดิบ ๆ เก็บอะไรก็ได้ แต่ type EmailAddress สร้างได้ก็ต่อเมื่อมีอีเมลที่ถูกต้องเท่านั้น นี่คือรากฐานของ Parse, Don’t Validate — parse ที่ขอบเขต (boundary) ให้เป็น type ที่แสดงค่าที่ไม่ถูกต้องไม่ได้ แล้วก็ไม่ต้อง validate ต่ออีกในภายหลัง
สัญญาณว่ากำลังละเมิดหลักการนี้
หัวข้อที่มีชื่อว่า “สัญญาณว่ากำลังละเมิดหลักการนี้”- Primitive obsession — ใช้
string,int, หรือboolดิบ ๆ แทนแนวคิด domain ที่มีข้อจำกัดเฉพาะ (เช่น อีเมล, เงิน, ช่วงวันที่) ทำให้ type ยอมรับค่าที่ไม่มีความหมายใน domain ได้ - field ที่ nullable หลายตัวแยกกัน แต่จริง ๆ ต้องพึ่งพากัน — เช่น
ShippedAtและTrackingNumberที่ควรมีค่าคู่กันเสมอ แต่ type ไม่ได้บังคับความสัมพันธ์นั้น - boolean flag หลายตัวที่รวมกันได้แบบผิดกฎ — สองหรือ3 flag ที่ในทางทฤษฎีรวมกันได้ 2ⁿ แบบ แต่ domain ยอมรับแค่บางแบบ (ดู Flags Over Objects และ Combinatorial Explosion)
- status เป็น string หรือ magic string — ค่าใด ๆ ก็ตั้งได้ทุกเมื่อ ไม่มีอะไรบังคับว่า transition ไหนถูกกฎ (ดู Magic Strings)
- validation logic เดิมซ้ำอยู่หลายที่ — ถ้าต้องเขียนเงื่อนไข “ถ้า A แล้วต้องมี B” ซ้ำ ๆ ในหลาย method นั่นคือสัญญาณว่าข้อจำกัดควรอยู่ใน type ไม่ใช่กระจายอยู่ใน code ที่เรียกใช้
- คอมเมนต์ที่อธิบาย invariant แทนที่จะบังคับมัน — คอมเมนต์อย่าง
// ต้องตั้งค่านี้ก่อนเรียกเมธอดนี้เสมอคือคำสารภาพว่ากฎนั้นควรอยู่ใน type ไม่ใช่ในเอกสาร
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”แหล่งอ้างอิง
หัวข้อที่มีชื่อว่า “แหล่งอ้างอิง”- ที่มา · deviq.com/principles/make-illegal-states-unrepresentable
- Designing with types: Making illegal states unrepresentable — Scott Wlaschin, F# for Fun and Profit
- Effective ML — Yaron Minsky, lecture slides (Cornell CS 3110 mirror)
- “Making Impossible States Impossible” — Richard Feldman, elm-conf
- Explore union types in C# 15 — .NET Blog, Microsoft