Kodokon kodokon.com

Conditional type และ infer: ตรรกะของคอมไพเลอร์

เชี่ยวชาญการกระจาย (distributivity) ของ conditional type, การจับคู่รูปแบบด้วย infer และกรณีขอบอย่าง never, any และ boolean

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

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

Conditional type คือกลไกการแตกกิ่ง (branching) ของระบบ type: T extends U ? X : Y ถามคอมไพเลอร์ว่า T สามารถกำหนดค่าให้ U ได้หรือไม่ แล้วจึงเลือกกิ่ง ยูทิลิตีมาตรฐานทั้งหมดเช่น ReturnType, Exclude หรือ Awaited ล้วนสร้างขึ้นบนพื้นฐานนี้ ในระดับนี้ คำถามไม่ใช่ว่าจะเขียนมันอย่างไรอีกต่อไป แต่เป็นการทำนายให้แม่นยำว่าคอมไพเลอร์ประเมินมันอย่างไร โดยเฉพาะเมื่อเกี่ยวข้องกับ union และ never

TYPESCRIPT
type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">;
// true

type B = IsString<string | number>;
// boolean, not false!
ผลลัพธ์ที่น่าประหลาดใจบน union: boolean

พฤติกรรมหลักคือ distributivity (การกระจาย): เมื่อ type ที่ถูกทดสอบเป็น naked type parameter (ไม่ได้ห่อไว้ใน tuple, array หรือ intersection) conditional จะถูกนำไปใช้แยกกันกับสมาชิกแต่ละตัวของ union แล้วจึงนำผลลัพธ์มารวมกัน IsString<string | number> จึงคำนวณเป็น true | false ซึ่งก็คือ boolean หากต้องการปิดการกระจายนี้ ให้ห่อทั้งสองฝั่งไว้ใน tuple: [T] extends [U] มีกรณีขอบที่น่ากลัวอย่างหนึ่ง: never คือ union ที่ว่างเปล่า การกระจายบนสมาชิกศูนย์ตัวจะไม่ประเมินกิ่งใดเลย และผลลัพธ์จะเป็น never ไม่ว่าคุณจะเขียนกิ่งใดไว้ก็ตาม

TYPESCRIPT
type ToArray<T> = T extends unknown ? T[] : never;
type NoDist<T> = [T] extends [unknown] ? T[] : never;

type A = ToArray<string | number>;
// string[] | number[]

type B = NoDist<string | number>;
// (string | number)[]

type IsNever<T> = [T] extends [never] ? true : false;
type C = IsNever<never>; // true thanks to the tuple
tuple ทำให้การกระจายเป็นกลาง และทำให้ never เชื่อถือได้

คีย์เวิร์ด infer แนะนำตัวแปร type ที่ถูกจับผ่าน pattern matching: คอมไพเลอร์พยายามจับคู่โครงสร้างของ type ที่ถูกทดสอบกับรูปแบบ และผูกตัวแปรเข้าไปหากการจับคู่สำเร็จ คุณสามารถวาง infer หลายตัวที่มีชื่อเดียวกันได้: ในตำแหน่ง covariant (ค่าที่ถูกผลิตออกมา เช่น return type) ตัวเลือกจะถูกรวมเข้าเป็น union; ในตำแหน่ง contravariant (พารามิเตอร์ของฟังก์ชัน) จะถูกรวมเข้าเป็น intersection กฎนี้เป็นผลโดยตรงจาก variance: มันเป็นทางเลือกที่ปลอดภัยเพียงทางเดียวในแต่ละทิศทาง

TYPESCRIPT
type ElementOf<T> =
  T extends readonly (infer E)[] ? E : never;

type FirstParam<T> =
  T extends (arg: infer P) => unknown ? P : never;

type Merged<T> =
  T extends { a: (x: infer P) => void;
              b: (x: infer P) => void }
    ? P : never;

type M = Merged<{
  a: (x: { id: string }) => void;
  b: (x: { tag: string }) => void;
}>; // { id: string } & { tag: string }
infer สองตัวในตำแหน่ง contravariant: intersection

ตั้งแต่ TypeScript 4.7 เป็นต้นมา infer รับข้อจำกัดแบบ inline ได้: infer N extends number กรองการจับ และตั้งแต่ 4.8 คอมไพเลอร์ยังแปลง numeric string literal ให้เป็น number literal ด้วยซ้ำ มีอีกจุดละเอียดอ่อนหนึ่งในสเปก: conditional type ที่ input ยังคงมี generic parameter ที่ยังไม่ได้แก้ค่าจะยังคง ถูกเลื่อน (deferred) ออกไป มันจะถูกประเมินเฉพาะที่จุด instantiation เท่านั้น ซึ่งอธิบายว่าทำไม error บางอย่างจึงปรากฏเฉพาะที่ฝั่งผู้เรียกใช้

TYPESCRIPT
type ToNumber<S extends string> =
  S extends `${infer N extends number}` ? N : never;

type Port = ToNumber<"8080">; // the literal 8080
type Bad = ToNumber<"abc">;   // never
ข้อจำกัดบน infer และการแปลง literal (4.8)

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

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

  1. type Exclude<boolean, true> คืออะไร?
    • boolean
    • false
    • never
    • true
  2. ด้วย type IsNever<T> = T extends never ? true : false; แล้ว IsNever<never> คืออะไร?
    • true
    • false
    • never
    • boolean
  3. infer P สองตัวที่มีชื่อเดียวกันวางไว้ในตำแหน่งพารามิเตอร์ของฟังก์ชันจะให้ผลเป็น:
    • union ของตัวเลือก
    • intersection ของตัวเลือก
    • ตัวเลือกตัวแรกที่พบ
    • error ของการคอมไพล์