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

Type-Level Programming

Prerequisite: โมดูลนี้ต่อยอดจาก Conditional Types และ Inference and infer — อ่านสองบทนั้นก่อนถ้ายังไม่ได้อ่าน เพราะทุกบทในนี้ถือว่าคุณรู้แล้วว่า conditional type กับ infer คืออะไร

ที่ผ่านมา type ทำหน้าที่ อธิบาย ข้อมูล ในโมดูลนี้ type จะเริ่ม คำนวณ จากข้อมูลนั้น จาก type หนึ่งคุณ derive อีก type ออกมาได้ — ทำให้ทุก field เป็น optional, pick key แค่บางส่วน, ดึง return type ของ function ออกมา หรือ parse string ให้เป็น union — ทั้งหมดที่ระดับ type โดย compiler ประเมินก่อน code รัน

นี่คือการเขียนโปรแกรมจริง ๆ type system มีตัวแปร (type parameter), การ branch (conditional type), การวนซ้ำ (mapped type และ recursion) และ function (generic type alias) มีคนเขียน arithmetic หรือแม้แต่ parser ด้วย type ล้วน ๆ คุณคงไม่ทำแบบนั้นในงานจริง — แต่เข้าใจกลไกนี้คือสิ่งที่ทำให้คุณ อ่าน type ของ library ออก และเขียน type เจ๋ง ๆ ไม่กี่ตัวที่คุ้มค่าจริง

บทเรียนสิ่งที่คุณจะได้เรียน
Mapped typesวนซ้ำ key ของ type เพื่อ transform แต่ละ property — สร้าง Partial และ Readonly เอง
keyof & indexed accesskeyof T และ T[K] — การ lookup ที่เป็นฐานของทุกอย่าง
Template literal typesคำนวณ string type และ pattern-match string ด้วย infer
The utility typesตัว built-in และวิธีที่ถูก implement จริง
Recursive typestype ที่เรียกตัวเอง — และจุดที่ compiler ขีดเส้น
flowchart TB
  keyof["keyof T
(get the keys)"] --> mapped["Mapped types
(iterate the keys)"]
  indexed["T[K]
(look up value types)"] --> mapped
  cond["Conditional types + infer
(branch and capture)"] --> util["Utility types
(Pick, Exclude, ReturnType)"]
  mapped --> util
  tmpl["Template literals
(compute strings)"] --> util
  util --> rec["Recursion
(repeat until done)"]
เครื่องมือระดับ type ประกอบกันอย่างไร

แทบทุก type ขั้นสูงในโลกจริงคือการผสม 5 primitive: keyof เพื่อเอา key, indexed access เพื่อ lookup value type, mapped type เพื่อวนซ้ำ, conditional type กับ infer เพื่อ branch และ capture, และ recursion เพื่อทำซ้ำ เรียน 5 ตัวนี้แล้วไฟล์ .d.ts ของ library จะเลิกเป็นอักษรอียิปต์โบราณ

ทุก claim ในโมดูลนี้ — “Foo<Bar> resolve เป็น Baz” — ต้องถูก ตรวจสอบ ไม่ใช่แค่มองด้วยตา comment // ^? เป็นแค่คำใบ้สำหรับคนอ่าน diff มันไม่ใช่สิ่งที่ tsc ตรวจสอบให้ วิธีมาตรฐานที่ทำให้ claim ระดับ type กลายเป็นสิ่งที่ compiler ตรวจจริง คือ type alias เล็ก ๆ สองตัว:

type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
// If Equal<A, B> is not literally `true`, this line fails to compile.
type Case1 = Expect<Equal<1 | 2, 1 | 2>>;

Expect<T extends true> คือ type parameter ที่มี constraint: คุณ instantiate มันได้เฉพาะกับสิ่งที่ assignable ให้ true เท่านั้น ป้อนอะไรที่ไม่ใช่ literal true เข้าไป — false หรือ boolean ที่กว้างกว่า — compiler จะ reject ด้วย TS2344 การ reject นั้นคือการที่ test ล้มเหลว type-level test suite ไม่มีอะไรมากไปกว่าไฟล์ที่เต็มไปด้วยบรรทัด Expect<Equal<..., ...>> ที่ต้อง compile ผ่านหมดทุกบรรทัด

วิธีที่ล่อใจให้เขียน Equal คือ “แต่ละฝั่ง extends อีกฝั่ง”:

type EqualNaive<A, B> = A extends B ? (B extends A ? true : false) : false;

ดูสมเหตุสมผลและผ่านทุก test ง่าย ๆ ที่คุณคิดจะเขียนด้วยมือ มันพังตรงสอง case ที่สำคัญที่สุดพอดี: any และ union

type EqualNaive<A, B> = A extends B ? (B extends A ? true : false) : false;
// EqualNaive<any, string> SHOULD be false -- any and string are not the same
// type -- but both of these compile with zero errors:
type NaiveAnyString = EqualNaive<any, string>;
const proof1: NaiveAnyString = true;
const proof2: NaiveAnyString = false;

ทั้งสอง assignment type-check ผ่านหมด ซึ่งเป็นไปได้ก็ต่อเมื่อ NaiveAnyString คือ boolean — ไม่ใช่ true หรือ false ที่ตัดสินใจได้เลย any extends string และ string extends any เป็น true ทั้งคู่ (นี่แหละคือสิ่งที่ทำให้ any unsound) นิยามแบบ naive เลยแยก “เท่ากัน” ออกจาก “ฝั่งหนึ่งเป็น any” ไม่ได้

union พังด้วยเหตุผลคนละแบบ — การเช็ค extends ของตัว naive เอง distribute ผ่าน naked union แบบเงียบ ๆ รันการเทียบใหม่ทีละ member แทนที่จะรันครั้งเดียว:

type EqualNaive<A, B> = A extends B ? (B extends A ? true : false) : false;
// The exact same type compared to itself. This has to be `true`.
type NaiveSame = EqualNaive<string | number, string | number>;
const proof3: NaiveSame = true;
const proof4: NaiveSame = false;

ทั้งคู่ compile ผ่านอีกแล้ว — string | number เทียบกับตัวมันเองได้ boolean ไม่ใช่ true เพราะ A extends B distribute ผ่าน member ของ A ในขณะที่ B ยังเป็น union ทั้งก้อน แล้ว B extends A ข้างในแต่ละกิ่งก็ distribute ผ่าน B อีกที สอง type ที่เหมือนกันเป๊ะให้ผลลัพธ์ที่ตัดสินใจไม่ได้

Equal ที่ถูกต้อง — แบบ (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false — หลบทั้งสองปัญหานี้ด้วยการไม่เอา A หรือ B ไปวางในตำแหน่งที่ถูกตรวจโดยตรงเลย แต่ละฝั่งถูกห่อด้วย generic function type ก่อน และ function type ถูกเทียบทั้งก้อน (ไม่ distribute และ any ก็ไม่ได้รับการปฏิบัติพิเศษแบบ “ทั้งสองกิ่งพร้อมกัน” ที่มันได้เมื่อมันเป็น naked checked type เอง) นี่คือ form ที่ harness verify:fences ของคอร์สนี้ และคู่ Equal/Expect ทุกคู่ที่คุณเคยเห็นในบทก่อนหน้า ใช้จริง

Naive Equal ยุบเหลือ boolean; ตัวจริงไม่เป็นแบบนั้น

type EqualNaive<A, B> = A extends B ? (B extends A ? true : false) : false;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
// naive: both compile -- proof that the result is `boolean`, not a real answer
type NaiveAnyString = EqualNaive<any, string>;
const naive1: NaiveAnyString = true;
const naive2: NaiveAnyString = false;
// correct: any is genuinely not string -- only `false` is assignable
type CorrectAnyString = Equal<any, string>;
const correct1: CorrectAnyString = false;
// const correct2: CorrectAnyString = true; -- would be a real TS2322 error
// naive: the exact same union compared to itself also collapses to boolean
type NaiveSame = EqualNaive<string | number, string | number>;
const naive3: NaiveSame = true;
const naive4: NaiveSame = false;
// correct: two identical types resolve to the literal `true`
type CorrectSame = Equal<string | number, string | number>;
const correct3: CorrectSame = true;
Open in TS Playground

อีกครึ่งหนึ่งของ harness คอร์สนี้ — // @expect-error TSxxxx — ก็เป็น type-level test เหมือนกัน แค่เล็งไปที่ diagnostic ของ compiler แทนที่จะเป็น type ที่ resolve ออกมา fence ที่ตั้งใจไม่ให้ compile ผ่านจะประกาศ error code ที่คาดไว้ในบรรทัดแรก; verify:fences จะทำให้ build fail ถ้า code นั้นไม่ปรากฏ หรือถ้ามี code อื่น ปรากฏมาด้วย รวมกับ Expect แล้ว ไฟล์เดียว assert ได้ทั้ง “อันนี้ resolve เป็น type นี้เป๊ะ” และ “อันนี้ตั้งใจให้เป็น compile error”:

// @expect-error TS2344
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
// passes: two identical unions really are equal
type CaseOk = Expect<Equal<string | number, string | number>>;
// intentionally wrong -- any is NOT string, so Equal<any, string> is `false`,
// and Expect<false> is a TS2344 constraint violation. The failure to compile
// is the test catching the (deliberately) incorrect claim.
type CaseWrong = Expect<Equal<any, string>>;

นี่คือเวอร์ชันเล็กที่สุดของสิ่งที่ Type-Level Testing จะครอบคลุมแบบเต็ม ๆ — เขียน type-level test suite จริง จัดระเบียบ case ของ Expect ต่อ utility และอ่าน error ของ constraint violation ว่าเป็น assertion ที่ล้มเหลว ไม่ใช่ bug ในโค้ดคุณ คู่ Equal/Expect ทุกคู่ที่ใช้ก่อนหน้านี้ในคอร์ส (Conditional Types, Inference & infer) และทุกที่ในโมดูลนี้ต่อจากนี้ก็คือ pattern เดียวกันเป๊ะ

flowchart TD
  claim["ข้อกล่าวอ้าง: Foo of Bar resolve เป็น Baz"] --> wrap["Expect of Equal ของทั้งสอง type"]
  wrap --> compiles{"compile ผ่านไหม?"}
  compiles -->|ผ่าน| holds["ข้อกล่าวอ้างเป็นจริง -- Equal ให้ literal true"]
  compiles -->|ไม่ผ่าน, TS2344| wrong["ข้อกล่าวอ้างเป็นเท็จ -- Equal ให้ false หรือ boolean"]
A type-level test turns a resolved-type claim into a compile error

มองว่า Expect<Equal<A, B>> เหมือน assert(a === b) ตอน runtime แค่ให้ type checker เป็นคนประเมินแทน node build ที่เขียวคือ test suite ที่ผ่าน comment // ^? ยังมีประโยชน์เป็นตัวช่วยอ่าน แต่มันไม่พิสูจน์อะไรด้วยตัวเอง — มีแค่เส้นหยักสีแดง (หรือการไม่มีมัน) เท่านั้นที่ถูกตรวจจริง

ทางเลือกข้อดีต้นทุน
EqualNaive (A extends B ? (B extends A ? ...))อ่านแล้วเข้าใจง่าย ไม่มี syntax แปลกใหม่ยุบเหลือ boolean — ไม่ใช่คำตอบที่ตัดสินใจได้ — สำหรับ any และสำหรับ union ที่เทียบกับตัวเอง ซึ่งเป็นสอง case ที่คุณต้องการให้ type test ถูกต้องมากที่สุด
Equal แบบ function-typereject any ถูกต้อง และจัดการ union ได้ เป็น form ที่ type-testing library จริงทุกตัว (tsd, expect-type, harness ของคอร์สนี้) ใช้ดูไม่มีที่มาจนกว่าคุณจะเห็นมันพังบน form แบบ naive — ควรเก็บ comment อธิบายไว้
Expect<T extends true> + @expect-errorเปลี่ยน claim เรื่อง resolved type ให้เป็น compiler error จริง ตรวจทุกครั้งที่รันดีเท่ากับ Equal ที่มันห่ออยู่เท่านั้น — Equal ที่พังให้ความมั่นใจปลอม ไม่ใช่รายงาน bug
  • comment // ^? ในบทเรียนของคอร์สนี้ถูก compiler ตรวจสอบ ไม่จริง — มันเป็นโน้ตสำหรับคนอ่าน มีแค่ Expect<Equal<...>> (หรือ @expect-error ที่ตั้งใจ) เท่านั้นที่ถูก tsc ตรวจจริง
  • A extends B ? (B extends A ? true : false) : false เป็นวิธีเขียน Equal ที่ถูกต้อง แค่ดูเทอะทะกว่านิดหน่อย ไม่จริง มันไม่ใช่แค่ผิดนิดหน่อย มันตัดสินใจไม่ได้เลยสำหรับสอง case ที่พบบ่อยที่สุดที่ type test ต้องครอบคลุม: any ในฝั่งไหนก็ตาม และการเทียบ union กับตัวเอง
  • type-level test ที่ compile ผ่านต้องแปลว่า type นั้นเท่ากันจริง ๆ มันแปลว่า Equal (ไม่ว่าจะนิยามแบบไหน) ให้ true ถ้า Equal เองเป็นเวอร์ชัน naive ที่พัง test ที่ “ผ่าน” ก็ซ่อนความต่างจริง ๆ ได้ — ความถูกต้องของ Equal ไม่ใช่ตัวเลือก มันคือตัว test ทั้งหมด

utility ระดับ type ที่ AI generate มักจะมาโดยไม่มี test พิสูจน์ว่ามัน resolve ตรงตามชื่อที่ตั้งไว้ — และเวลามี test ก็ควรเช็คว่ามันใช้ Equal แบบไหน:

  • Equal<A, B> ที่เขียนมือแบบ A extends B ? (B extends A ? true : false) : false compile ผ่านสวย ๆ และดูเหมือนใช้ได้กับตัวอย่างในไฟล์เดียวกัน — ปัญหาจะโผล่แค่ตอนเจอ any หรือ union ที่เหมือนกันเป๊ะ ซึ่งเป็น case ที่มีโอกาสน้อยที่สุดที่จะโผล่ใน smoke test เร็ว ๆ
  • type utility ที่ไม่มี Expect/Equal probe อยู่ใกล้ ๆ เลยแม้แต่ตัวเดียว กำลังเชื่อ comment // ^? (หรือไม่มีอะไรเลย) เป็นหลักฐาน — ถามว่าอะไรพิสูจน์ claim นั้นจริง ๆ ใน PR description
  • @ts-expect-error (ไม่ใช่ @ts-ignore) บน type-level test ที่ควรพังนั้นถูกต้องและตั้งใจ — ตรงข้ามกับกลิ่น @ts-ignore ที่พูดถึงใน glossary — เช็คว่าไฟล์ยังคงพังดังๆ ถ้าโค้ดเลิกผิดจริง

🐛 หา bug ในโค้ดนี้

type IsEqual<A, B> = A extends B ? (B extends A ? true : false) : false;
type Expect<T extends true> = T;
// "prove" that a generic cache key helper only ever returns string keys
type CacheKey<T> = T extends string | number ? `key:${T}` : never;
type Test1 = Expect<IsEqual<CacheKey<any>, never>>;
เฉลย

โค้ดนี้ compile ผ่านโดยไม่มี error เลย แต่มันไม่ได้พิสูจน์อะไรเกี่ยวกับ CacheKey<any> เลย IsEqual<CacheKey<any>, never> ใช้นิยาม naive แบบ mutual-extends — และเพราะ any อยู่ฝั่งซ้าย CacheKey<any> extends never กับ never extends CacheKey<any> เป็น true ทั้งคู่ไม่ว่า CacheKey<any> จะ resolve เป็นอะไรจริง ๆ (any short-circuit ทั้งสองทิศทางของการเช็คความเท่ากันแบบ naive เหมือนกับที่มันทำกับ EqualNaive<any, string> ข้างบน) “test” นี้จะผ่านแม้ CacheKey<any> จะเป็น string, boolean หรืออะไรก็ตาม — มันไม่ได้ทดสอบ claim นั้นเลย เปลี่ยนไปใช้ Equal ตัวจริง (form function-type) ทำให้ test มีความหมาย: Equal<CacheKey<any>, never> resolve เป็น false ถูกต้อง เพราะ CacheKey<any> จริง ๆ คือ `key:${any}` ที่ยุบเหลือ string แบบ any ไม่ใช่ never — ซึ่งเป็นข้อมูลที่มีประโยชน์เองที่การเช็คแบบ naive ซ่อนไว้

"type-level programming" หมายความว่าอะไร?
5 primitive ใดที่ประกอบกันเป็น type ขั้นสูงส่วนใหญ่?
generic type อย่าง Box<T> เข้าใจได้ดีที่สุดว่าเป็น:
R คืออะไร และทำไมมันเป็นปัญหาสำหรับ type-level test?
type EqualNaive<A, B> = A extends B ? (B extends A ? true : false) : false;
type R = EqualNaive<any, string>;
อันนี้ compile ผ่านไหม และทำไม?
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
type Case = Expect<Equal<any, string>>;
ทำไม comment // ^? ในคอร์สนี้ถึงไม่นับเป็น type-level test?