Kodokon kodokon.com

never, ความครบถ้วน และ discriminated union ขั้นสูง

คุณจะจำลองสถานะต่าง ๆ ด้วย discriminated union และล็อกความครบถ้วนของมันไว้ด้วยชนิด never

10 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน Kodokon

never คือชนิดว่างเปล่า: ไม่มีค่าใดอาศัยอยู่ในนั้น มันปรากฏขึ้นเมื่อคอมไพเลอร์พิสูจน์ได้ว่ามีเส้นทางโค้ดที่เป็นไปไม่ได้ - เช่นหลังจากไล่จนครบทุกสมาชิกของ union แล้ว คุณสมบัตินี้ทำให้มันเป็นเครื่องมือออกแบบ: ถ้าคุณทำให้คอมไพเลอร์บอกว่า never ในสาขา default ของ switch ได้ คุณก็มีหลักฐานแบบ static ว่าทุกกรณีถูกจัดการแล้ว นี่คือรากฐานของ การตรวจความครบถ้วน (exhaustiveness checking)

คุณได้รู้จัก discriminated union มาแล้วในโมดูลก่อนหน้า ตอนนี้ถึงเวลาเปลี่ยนมันให้เป็นเครื่องมือออกแบบตัวจริง เมื่อเทียบกับ interface เดียวที่ยัดฟิลด์ optional เข้าไปมากมาย discriminated union ทำให้ สถานะที่เป็นไปไม่ได้แสดงตัวออกมาไม่ได้: คุณไม่สามารถมีทั้ง data และ error พร้อมกัน หรือมีความสำเร็จโดยไม่มีข้อมูล มันคือรูปแบบที่นิยมใช้กับสถานะการโหลด ผลของการดำเนินการ และข้อความเหตุการณ์

TYPESCRIPT
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: FetchState<string[]>): string {
  switch (state.status) {
    case "idle": return "Waiting";
    case "loading": return "Loading…";
    case "success": return state.data.join(", ");
    case "error": return state.error.message;
  }
}
data มีอยู่เฉพาะในตัวแปร success เท่านั้น: เข้าถึงจากที่อื่นไม่ได้เลย

คุณเคยพบ guard assertNever ที่ถูกเรียกในสาขา default มาแล้ว ตอนนี้มาผ่ามันดูว่ามันทำงานอย่างไรกันแน่ ตราบใดที่ทุกกรณีถูกครอบคลุม พารามิเตอร์ของมันจะมีชนิด never ตรงนั้นและทุกอย่างคอมไพล์ผ่าน แต่ถ้าเพิ่มตัวแปรเข้าไปใน union โดยไม่อัปเดต switch ตัวแปรที่ถูกลืมจะไม่สามารถ assign ให้ never ได้อีกต่อไป: error ตอนคอมไพล์ พร้อมชื่อของกรณีที่หายไปในข้อความ คุณเปลี่ยนบั๊กตอน production ให้กลายเป็น build ที่ถูกปฏิเสธ

TYPESCRIPT
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; width: number; height: number };

function assertNever(value: never): never {
  throw new Error("Unhandled case: " + String(value));
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.side ** 2;
    case "rect": return shape.width * shape.height;
    default: return assertNever(shape);
  }
}
การเพิ่มตัวแปรเข้าไปใน Shape ทำให้การคอมไพล์ของ area พัง

มีความละเอียดอ่อนขั้นสูงสองข้อที่ควรใส่ใจ ข้อแรก การ narrow ด้วยตัวแบ่งแยกจะแพร่ไปยังโครงสร้างที่ซ้อนกันก็ต่อเมื่อคุณตรวจอ็อบเจกต์ที่ห่อหุ้มโดยตรง การ destructure ตัวแบ่งแยกออกมาเดี่ยว ๆ จะตัดความเชื่อมโยงนั้น (ยกเว้นกรณีที่รองรับตั้งแต่ TypeScript 4.6 สำหรับการ destructure ร่วมกัน) ข้อสอง never ถูกดูดกลืนใน union (string | never ก็คือ string): นี่คือสิ่งที่ทำให้ conditional type กรองตัวแปรออกได้ เหมือนที่ Exclude ทำ

TYPESCRIPT
type Event =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string };

type EventOf<T extends Event["type"]> =
  Extract<Event, { type: T }>;

type ClickEvent = EventOf<"click">;

function handle<T extends Event["type"]>(
  type: T,
  handler: (event: EventOf<T>) => void
): void {
  /* register the handler */
}

handle("click", (e) => console.log(e.x, e.y));
Extract กรอง union ตามตัวแบ่งแยก: handler จึงแม่นยำ

ทดสอบความรู้

ตรวจสอบว่าคุณจำประเด็นสำคัญของบทเรียนนี้ได้ครบถ้วน

  1. ทำไม discriminated union จึงดีกว่า interface ที่มีฟิลด์ optional สำหรับสถานะการโหลด?
    • มันใช้หน่วยความจำน้อยกว่าตอน runtime
    • มันทำให้การผสมที่ขัดแย้งกัน (data และ error พร้อมกัน) แสดงตัวออกมาไม่ได้
    • มันเลี่ยงการเขียนคำสั่ง switch
    • มันเป็นข้อบังคับใน strict mode
  2. จะเกิดอะไรขึ้นถ้าคุณเพิ่มตัวแปรเข้าไปใน Shape โดยไม่แก้ switch ที่มี assertNever?
    • เกิด exception ทุกครั้งที่เรียก area
    • โค้ดคอมไพล์ผ่านแต่ตัวแปรใหม่คืน undefined
    • การคอมไพล์ล้มเหลวเพราะตัวแปรที่ถูกลืม assign ให้ never ไม่ได้
  3. ชนิด string | never คืออะไร?
    • never
    • string
    • unknown
    • string | undefined