Type-Level Programming
Prerequisite: โมดูลนี้ต่อยอดจาก Conditional Types และ Inference and infer — อ่านสองบทนั้นก่อนถ้ายังไม่ได้อ่าน เพราะทุกบทในนี้ถือว่าคุณรู้แล้วว่า conditional type กับ infer คืออะไร
type คือภาษาหนึ่ง
หัวข้อที่มีชื่อว่า “type คือภาษาหนึ่ง”ที่ผ่านมา 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 access | keyof T และ T[K] — การ lookup ที่เป็นฐานของทุกอย่าง |
| Template literal types | คำนวณ string type และ pattern-match string ด้วย infer |
| The utility types | ตัว built-in และวิธีที่ถูก implement จริง |
| Recursive types | type ที่เรียกตัวเอง — และจุดที่ 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 ขั้นสูงในโลกจริงคือการผสม 5 primitive: keyof เพื่อเอา key, indexed access เพื่อ lookup value type, mapped type เพื่อวนซ้ำ, conditional type กับ infer เพื่อ branch และ capture, และ recursion เพื่อทำซ้ำ เรียน 5 ตัวนี้แล้วไฟล์ .d.ts ของ library จะเลิกเป็นอักษรอียิปต์โบราณ
Type-level testing: พิสูจน์ว่า type resolve เป็นอะไรจริง ๆ
หัวข้อที่มีชื่อว่า “Type-level testing: พิสูจน์ว่า type resolve เป็นอะไรจริง ๆ”ทุก 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 แบบตรงไปตรงมาถึงผิด
หัวข้อที่มีชื่อว่า “ทำไม Equal แบบตรงไปตรงมาถึงผิด”วิธีที่ล่อใจให้เขียน 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 answertype NaiveAnyString = EqualNaive<any, string>;const naive1: NaiveAnyString = true;const naive2: NaiveAnyString = false;
// correct: any is genuinely not string -- only `false` is assignabletype 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 booleantype 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; @expect-error เป็นตัว assertion
หัวข้อที่มีชื่อว่า “@expect-error เป็นตัว assertion”อีกครึ่งหนึ่งของ 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 TS2344type 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 equaltype 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 เดียวกันเป๊ะ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”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"] มองว่า 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-type | reject 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 ทั้งหมด
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”utility ระดับ type ที่ AI generate มักจะมาโดยไม่มี test พิสูจน์ว่ามัน resolve ตรงตามชื่อที่ตั้งไว้ — และเวลามี test ก็ควรเช็คว่ามันใช้ Equal แบบไหน:
Equal<A, B>ที่เขียนมือแบบA extends B ? (B extends A ? true : false) : falsecompile ผ่านสวย ๆ และดูเหมือนใช้ได้กับตัวอย่างในไฟล์เดียวกัน — ปัญหาจะโผล่แค่ตอนเจอanyหรือ union ที่เหมือนกันเป๊ะ ซึ่งเป็น case ที่มีโอกาสน้อยที่สุดที่จะโผล่ใน smoke test เร็ว ๆ- type utility ที่ไม่มี
Expect/Equalprobe อยู่ใกล้ ๆ เลยแม้แต่ตัวเดียว กำลังเชื่อ 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 keystype 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 ซ่อนไว้