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

Generics

สมมติคุณอยากได้ function ที่คืนค่าอะไรก็ตามที่คุณส่งเข้าไป ถ้าไม่มี generics คุณจะเจอทางเลือกที่แย่ทั้งคู่:

// Option A: one type only — not reusable
function identityStr(x: string): string { return x; }
// Option B: any — reusable, but throws away all type information
function identityAny(x: any): any { return x; }
const a = identityAny(42);
a.toUpperCase(); // 💥 no error at compile time — any hides the bug

any ทำให้ function reuse ได้ก็จริง แต่ทำให้ compiler ตาบอด — ความเชื่อมโยงระหว่าง type ของ input กับ output หายไปหมด Generics เก็บความเชื่อมโยงนั้นไว้

// @expect-error TS2339
function identity<T>(x: T): T { return x; }
const n = identity(42); // T inferred as number → n: number
const s = identity("hi"); // T inferred as string → s: string
n.toUpperCase(); // ❌ Error: number has no toUpperCase — the bug is caught

T คือ type parameter — ตัวยึดที่ผู้เรียก (หรือ inference) เป็นคนเติมค่าให้ function ตัวนี้ทำงานได้กับทุก type และ return type ก็ยังตาม type ของ argument ได้เป๊ะ

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"]
A generic is one implementation over many types
บทเรียนสิ่งที่คุณจะได้เรียน
Functions & constraintstype parameter, constraint ด้วย extends, default type parameter
Classes & interfacesgeneric class, interface, type parameter ต่อ method และ variance
Conditional typesT extends U ? X : Y และการ distribute ผ่าน union
Inference & infertype argument ถูก infer อย่างไร และการดึง type ออกด้วย infer
Varianceannotation 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 compiles
console.log(user.name.toUpperCase());
// @run
interface 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
Open in TS Playground

ความต่างไม่ได้อยู่ที่จำนวนครั้งที่เขียน T แต่อยู่ที่ว่า T ผูกสิ่งที่ผู้เรียกส่งเข้ามากับสิ่งที่ผู้เรียกได้กลับไปจริงหรือเปล่า T ของ box ทำแบบนั้น ส่วน T ของ parseAs ไม่ทำ — มันเป็นแค่ label ที่ผู้เรียกแปะบนค่าที่ function ไม่เคยตรวจสอบเลย ซึ่งก็คือสิ่งที่ cast ทำเป๊ะ ๆ ส่วนที่เหลือของโมดูลนี้คือการสร้าง generic ที่ type parameter เชื่อมตำแหน่งจริง ๆ — และสองบทข้างหน้า (classes กับ conditional types + inference) จะโชว์ว่าความเชื่อมโยงนั้นเชื่อได้แค่ไหน

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"]
A generic is a promise the signature makes and the implementation must keep

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 ก็แค่เพิ่มพิธีกรรมโดยไม่เพิ่มความปลอดภัย

ก่อนเชื่อ 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 ที่ไม่เคยถูกตรวจกับความเป็นจริงเลย

generics เก็บอะไรไว้ที่ any ทิ้งไป?
ใน function identity<T>(x: T): T T คืออะไร?
ชื่อของ "implementation เดียวที่ parameterize ด้วย type" คืออะไร?
โค้ดนี้ compile ผ่านไหม เพราะอะไร?
interface 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());
ทำไม function box<T>(value: T): Box<T> ถึงนับเป็นความเชื่อมโยง generic จริง ทั้งที่ T ปรากฏแค่ครั้งเดียวใน *parameter*?
generic class หรือ interface ก็มีปัญหาเรื่อง variance ได้เหมือนกัน — เช่นการ assign Stack<Dog> ให้ตัวแปรที่ type เป็น Stack<Animal> เรื่องนี้ถูกสอนเต็ม ๆ อยู่ที่บทไหนของโมดูลนี้?