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

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 ใหม่​ให้​แสดง​ได้​เฉพาะ​สถานะ​ที่​ถูกต้อง การ​ป้องกัน (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 ไม่ใช่​ใน​เอกสาร