Generics
ปัญหาที่ generics มาแก้
หัวข้อที่มีชื่อว่า “ปัญหาที่ generics มาแก้”สมมติคุณอยากได้ function ที่คืนค่าอะไรก็ตามที่คุณส่งเข้าไป ถ้าไม่มี generics คุณจะเจอทางเลือกที่แย่ทั้งคู่:
// Option A: one type only — not reusablefunction identityStr(x: string): string { return x; }
// Option B: any — reusable, but throws away all type informationfunction identityAny(x: any): any { return x; }
const a = identityAny(42);a.toUpperCase(); // 💥 no error at compile time — any hides the bugany ทำให้ function reuse ได้ก็จริง แต่ทำให้ compiler ตาบอด — ความเชื่อมโยงระหว่าง type ของ input กับ output หายไปหมด Generics เก็บความเชื่อมโยงนั้นไว้
// @expect-error TS2339function identity<T>(x: T): T { return x; }
const n = identity(42); // T inferred as number → n: numberconst s = identity("hi"); // T inferred as string → s: stringn.toUpperCase(); // ❌ Error: number has no toUpperCase — the bug is caughtT คือ type parameter — ตัวยึดที่ผู้เรียก (หรือ inference) เป็นคนเติมค่าให้ function ตัวนี้ทำงานได้กับทุก type และ return type ก็ยังตาม type ของ argument ได้เป๊ะ
จริง ๆ แล้ว generics คืออะไร
หัวข้อที่มีชื่อว่า “จริง ๆ แล้ว generics คืออะไร”generic คือ function ในระดับ type — รับ type เป็น input แล้วผลิต type เป็น output identity<T> บอกว่า “สำหรับ type T ใด ๆ ก็ตาม รับ T แล้วคืน T” อันนี้เรียกว่า parametric polymorphism — implementation เดียว ที่ parameterize ด้วย type
flowchart LR impl["generic เดียว identity ของ T"] --> callN["เรียกด้วย number คืน number"] impl --> callS["เรียกด้วย string คืน string"] impl --> callU["เรียกด้วย User คืน User"]
โมดูลนี้ครอบคลุมอะไรบ้าง
หัวข้อที่มีชื่อว่า “โมดูลนี้ครอบคลุมอะไรบ้าง”| บทเรียน | สิ่งที่คุณจะได้เรียน |
|---|---|
| Functions & constraints | type parameter, constraint ด้วย extends, default type parameter |
| Classes & interfaces | generic class, interface, type parameter ต่อ method และ variance |
| Conditional types | T extends U ? X : Y และการ distribute ผ่าน union |
Inference & infer | type argument ถูก infer อย่างไร และการดึง type ออกด้วย infer |
| Variance | annotation in/out, variance ของ Promise/Map/Set/Array และกลิ่น AI ที่ variance ทำให้เกิด |
ความเชื่อมโยงมีอยู่ก็ต่อเมื่อ implementation รักษามันไว้
หัวข้อที่มีชื่อว่า “ความเชื่อมโยงมีอยู่ก็ต่อเมื่อ implementation รักษามันไว้”ทุกอย่างข้างบนขาย generics ด้วยสัญญาข้อเดียว: identity<T> คืน type ที่รับเข้ามา เป๊ะ ต่างจาก identityAny สัญญานี้เป็นคุณสมบัติของ signature และ compiler บังคับใช้ที่ทุกจุดเรียก — แต่ไม่มีอะไรบังคับให้ implementation รักษาความเชื่อมโยงนั้นจริง ๆ ข้างใน “generic” ที่ปรากฏใน signature เพียงเพื่อให้ดูปลอดภัย แต่ตัว body แอบตัดความเชื่อมโยงด้วย cast ก็ไม่ได้ปลอดภัยไปกว่า identityAny เลย:
interface User { id: string; name: string }
// T appears ONLY in the return position — nothing links it to any argument,// so it can never be inferred from a call. This "generic" is really `as T`// with extra steps: the caller supplies T by hand, and the compiler trusts it.function parseAs<T>(json: string): T { return JSON.parse(json);}
const user = parseAs<User>('{"id":"1"}'); // no "name" field at all — still compilesconsole.log(user.name.toUpperCase());// @runinterface User { id: string; name: string }
function parseAs<T>(json: string): T { return JSON.parse(json);}
const user = parseAs<User>('{"id":"1"}');console.log(user.name.toUpperCase());โค้ดนี้ compile ผ่านโดยไม่มี error เลย — JSON.parse คืน any และ any assignable ให้อะไรก็ได้ รวมถึง T ที่ผู้เรียกเลือกเอง พอรันจริงมันพัง:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')เทียบกับ generic ที่รักษาสัญญาไว้จริง เพราะ type parameter ของมันเชื่อม argument กับผลลัพธ์เข้าด้วยกันจริง ๆ:
A real link between input and output
interface Box<T> { value: T }
// T appears once in the parameter list — but it ALSO appears in the return// type, linking the argument to the result. That is a real generic link,// not a decoration, even though T shows up only once among the parameters.function box<T>(value: T): Box<T> { return { value };}
const b = box("hi");// ^? Box<string>console.log(b.value.toUpperCase()); // ✅ still string — the link survived ความต่างไม่ได้อยู่ที่จำนวนครั้งที่เขียน T แต่อยู่ที่ว่า T ผูกสิ่งที่ผู้เรียกส่งเข้ามากับสิ่งที่ผู้เรียกได้กลับไปจริงหรือเปล่า T ของ box ทำแบบนั้น ส่วน T ของ parseAs ไม่ทำ — มันเป็นแค่ label ที่ผู้เรียกแปะบนค่าที่ function ไม่เคยตรวจสอบเลย ซึ่งก็คือสิ่งที่ cast ทำเป๊ะ ๆ ส่วนที่เหลือของโมดูลนี้คือการสร้าง generic ที่ type parameter เชื่อมตำแหน่งจริง ๆ — และสองบทข้างหน้า (classes กับ conditional types + inference) จะโชว์ว่าความเชื่อมโยงนั้นเชื่อได้แค่ไหน
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart TD sig["Signature: identity of T takes T returns T"] --> promise["Promise: whatever comes in, comes back out unchanged"] promise --> kept["Implementation actually uses the argument (box, identity, first)"] --> real["Link is real — compiler-checked at every call"] promise --> broken["Implementation ignores the argument, casts, or parses untyped data (parseAs, JSON.parse as T)"] --> fake["Link is fake — caller's T is trusted, never verified"]
generic signature คือสัญญา แต่ TypeScript ตรวจแค่ว่า shape ของสัญญาสอดคล้องกัน — ทุกที่ที่ T ปรากฏต้องเข้ากันกับทุกที่อื่น มันไม่ตรวจ (และตรวจไม่ได้) ว่า body ของ function ผลิต output มาจาก input ตามที่ signature บอกจริงหรือเปล่า identity<T>(x: T): T { return x; } รักษาสัญญาได้เพราะมันถูกบังคับแบบง่าย ๆ — ไม่มีอะไรอื่นให้ return ทันทีที่ body ของ function ผลิตค่า type T ได้โดยไม่ต้องอิง argument เลย — parse JSON, อ่าน any, cast — signature ก็กลายเป็นคำกล่าวอ้างที่ลอยอยู่บนความเชื่อ เหมือนกับ custom type guard จากบท narrowing ที่ compiler เชื่อโดยไม่ตรวจ body
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| แนวทาง | ข้อดี | ต้นทุน |
|---|---|---|
any | เขียนง่ายที่สุด รับทุกอย่าง | ทิ้งความเชื่อมโยง input↔output ไปทั้งหมด — ทุกที่ที่ใช้ไม่ถูกตรวจเลย |
Generic (T เชื่อมตำแหน่งจริง) | compiler บังคับความเชื่อมโยงที่ทุกจุดเรียก สำหรับทุก type ที่เป็นไปได้ | ต้องออกแบบ signature ที่ T infer ได้จริง ไม่ใช่แค่ประกาศไว้ |
generic ที่ใช้เป็น cast แฝง (T อยู่แค่ใน output) | หน้าตาใน signature เหมือน generic ที่ปลอดภัยเป๊ะ | infer ไม่ได้ ต้องให้ผู้เรียกส่งมาเอง และเชื่อได้แค่เท่าที่ผู้เรียกอ้าง |
| overload set | บันทึกคู่ input→output ที่แน่นอนจำนวนน้อยได้ทีละคู่ | ขยายไม่ไหวเมื่อ case เยอะขึ้น ดูตัวอย่างที่ overload ชนะ generic ในบท Generic Functions & Constraints |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- function ที่มี
<T>ใน signature ปลอดภัยเรื่อง type โดยอัตโนมัติ ปลอดภัยก็ต่อเมื่อTมาจากสิ่งที่ผู้เรียกส่งเข้ามาจริง ๆTที่ปรากฏแค่ใน return position ถูกเติมโดย annotation ของผู้เรียกเอง โดยไม่มีอะไรมาตรวจสอบ — ไม่ต่างจาก cast ด้วยas - generic function ดีกว่า function ที่ type ด้วย
anyเสมอ ดีกว่าก็ต่อเมื่อ type parameter ของ generic เชื่อมตำแหน่งที่ผู้เรียกควบคุมกับตำแหน่งที่ผู้เรียกอ่านค่ากลับจริง ๆ ไม่งั้นมันก็คือanyที่สวมหน้ากาก signature ที่ดูปลอดภัยกว่า - generics มีไว้เพื่อทำให้โค้ด “ปลอดภัยเรื่อง type มากขึ้น” แบบเป็นนามธรรม จริง ๆ มันมีไว้เพื่อเหตุผลเดียว: รักษาความเชื่อมโยงระหว่าง input กับ output ที่ type ตรง ๆ ตัวเดียวหรือ
anyจะทำหาย ถ้าไม่มีความเชื่อมโยงให้รักษา generic ก็แค่เพิ่มพิธีกรรมโดยไม่เพิ่มความปลอดภัย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ generic function ในโค้ดที่ generate มา หาทุกตำแหน่งที่ T (หรือ type parameter ใด ๆ) ปรากฏ แล้วถามว่ามีอย่างน้อยหนึ่งตำแหน่งเป็น parameter ที่ compiler ตรวจได้จริงตอนเรียกหรือเปล่า:
- type parameter ที่ปรากฏ แค่ใน return type (
function parseAs<T>(json: string): T) infer ไม่ได้เลย — ผู้เรียกเป็นคนส่งมาแล้วถูกเชื่อ เหมือน cast เป๊ะ ๆ นี่คือรูปแบบที่พบบ่อยที่สุดแบบหนึ่งในโค้ด data-fetching และ parsing ที่ AI เขียน - type parameter ที่ปรากฏใน parameter ที่ตัวมันเองไม่มี type หรือเป็น
any(ผลจากJSON.parse,fetch().json(), callback จาก third-party ที่ type เป็นany) ให้ภาพว่ามีความเชื่อมโยงทั้งที่ไม่มีความปลอดภัยเลย — ฝั่ง “input” ก็ไม่เคยถูกตรวจจริงเหมือนกัน - generic wrapper ที่ cast parameter ของตัวเองเป็น
anyหรือunknownทันทีข้างใน แล้ว cast ผลลัพธ์กลับเป็นTมีปัญหาเดียวกับparseAsแค่กระจายอยู่สองบรรทัดแทนที่จะเป็นบรรทัดเดียว
🐛 หา bug ในโค้ดนี้
interface Product { id: string; price: number }
function fetchTyped<T>(url: string): Promise<T> {return fetch(url).then((r) => r.json());}
async function run() {const product = await fetchTyped<Product>("/api/product/1");console.log(product.price.toFixed(2));}เฉลย
โค้ดนี้ compile ผ่านโดยไม่มี error เลย และยังพังหรือทำงานผิดแบบเงียบ ๆ ตอนรันได้ T ปรากฏแค่ใน return position ของ fetchTyped — ไม่เคยถูกดึงมาจาก url หรือจากอะไรที่ function ตรวจสอบจริง .json() คืน Promise<any> และ any assignable ให้ T ที่ผู้เรียกเลือกโดยไม่มีการตรวจสอบเลย ถ้า response จริงของ endpoint ไม่ตรงกับ Product (field ถูกเปลี่ยนชื่อ, price ถูกส่งมาเป็น string, ห่อด้วย { data: ... }) fetchTyped<Product> ก็ยัง “คืน” Product ในสายตาของ type system และ product.price.toFixed(2) จะพังหรือทำงานผิดแบบเงียบ ๆ ขึ้นกับว่าจริง ๆ ได้อะไรกลับมา ทางแก้เหมือนกับบท Typing APIs & Boundaries: validate shape ที่ boundary (schema library หรืออย่างน้อยก็ runtime check) แทนการยืนยันผ่าน type parameter ของ generic ที่ไม่เคยถูกตรวจกับความเป็นจริงเลย