Generic Functions & Constraints
type parameter ส่วนใหญ่ถูก infer ไม่ใช่เขียนเอง
หัวข้อที่มีชื่อว่า “type parameter ส่วนใหญ่ถูก infer ไม่ใช่เขียนเอง”ส่วนใหญ่คุณไม่ต้องเขียน type argument เลย — inference เติมให้จาก value ที่คุณส่งเข้าไป:
function first<T>(arr: T[]): T | undefined { return arr[0];}
const a = first([1, 2, 3]);// ^? number | undefined — T inferred as numberconst b = first(["x", "y"]);// ^? string | undefined — T inferred as stringคุณ ส่ง type argument ตรง ๆ ได้ (first<number>([1, 2, 3])) แต่แทบไม่จำเป็น type parameter ไหลมาจากจุดที่เรียก
constraint: extends จำกัดว่า T เป็นอะไรได้บ้าง
หัวข้อที่มีชื่อว่า “constraint: extends จำกัดว่า T เป็นอะไรได้บ้าง”T ที่ไม่มี constraint เป็นอะไรก็ได้ ดังนั้นคุณจะสมมติว่า property อะไรไม่ได้เลย เมื่อ function ต้องการให้ T มี shape บางอย่าง ให้ constrain ด้วย extends:
// @expect-error TS2339 TS2345// ❌ without a constraint, T might not have .lengthfunction longestLoose<T>(a: T, b: T) { return a.length > b.length ? a : b; // Error: Property 'length' does not exist on type 'T'}
// ✅ constrain T to things that have a numeric lengthfunction longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b;}
longest([1, 2], [1, 2, 3]); // ✅ arrays have length → returns number[]longest("ab", "abc"); // ✅ strings have length → returns stringlongest(1, 2); // ❌ number has no lengthอ่าน T extends { length: number } ว่า “T ต้อง assignable ให้ { length: number }” — คือ T เป็น subtype อะไรก็ได้ที่มี length เป็น number อย่างน้อย ที่สำคัญคือ return type ยังเป็น T อยู่ ดังนั้นคุณยังได้ type ที่ เฉพาะเจาะจง (number[], string) ไม่ได้ widen ไปเป็น constraint
constraint แบบ keyof: getter ที่ปลอดภัย
หัวข้อที่มีชื่อว่า “constraint แบบ keyof: getter ที่ปลอดภัย”constraint อ้างถึง type parameter อีกตัวได้ ตัวอย่างคลาสสิกคือ get ที่ type-safe:
// @expect-error TS2345function getProp<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key];}
const user = { name: "Ada", age: 36 };const label = getProp(user, "name");// ^? stringconst age = getProp(user, "age");// ^? numbergetProp(user, "email"); // ❌ "email" is not a key of userK extends keyof T บอกว่า key ต้องเป็นหนึ่งใน key จริง ๆ ของ T และ return type T[K] คือ type ของ property ตัวนั้นเป๊ะ ๆ นี่คือกระดูกสันหลังของ utility เกี่ยวกับ object ที่ type-safe
default type parameter
หัวข้อที่มีชื่อว่า “default type parameter”เหมือน default argument ของ function — type parameter มี default ได้ ซึ่งจะถูกใช้เมื่อ infer ไม่ได้และไม่ได้ส่งมา:
interface Container<T = string> { value: T;}
const c1: Container = { value: "hi" }; // T defaults to stringconst c2: Container<number> = { value: 1 }; // T explicitly numberdefault พบบ่อยกับ generic class และ interface ที่มีค่า fallback ที่สมเหตุสมผล
อย่า generic เกินจำเป็น — แต่ “ใช้แค่ครั้งเดียว” ต้องแยกละเอียด
หัวข้อที่มีชื่อว่า “อย่า generic เกินจำเป็น — แต่ “ใช้แค่ครั้งเดียว” ต้องแยกละเอียด”generics เป็นเครื่องมือ ไม่ใช่เป้าหมาย type parameter ที่ปรากฏ แค่ครั้งเดียวในทั้ง signature และไม่ปรากฏที่อื่นเลย มักเป็นความผิดพลาด — ไม่ได้เชื่อมอะไรเลย ก็เลยควรเป็น type ตรง ๆ หรือ unknown ไปเลย:
// pointless generic: T is used once, links nothingfunction logLoose<T>(x: T): void { console.log(x); }// just write:function log(x: unknown): void { console.log(x); }แต่ “ใช้แค่ครั้งเดียว” ต้องอ่านให้ละเอียด กฎนี้ไม่ใช่ “นับใน parameter list” — แต่คือ “type parameter ตัวนี้เชื่อม input กับ output อยู่หรือเปล่า” T ที่ปรากฏแค่ครั้งเดียว ใน parameter แต่ปรากฏใน return type ด้วย ก็ยังเชื่อมสองตำแหน่งอยู่ นั่นทำให้มันเป็น generic จริง ไม่ใช่กลิ่นไม่ดี:
// @expect-error TS2339interface Tagged<T> { value: T }
// T appears once in the parameter list — but it ALSO appears in the return// type, tying the argument to the result. That IS a link, so this is fine.function tag<T>(value: T): Tagged<T> { return { value };}
const t = tag(42);// ^? Tagged<number>t.value.toUpperCase(); // ❌ number has no toUpperCase — the link still catches real bugsเทียบสองอันนี้: logLoose<T>(x: T): void มี T อยู่ตำแหน่งเดียวจริง ๆ — return type เป็น void ดังนั้นไม่มีอะไรต่อจากนั้นถูกตรวจกับมันเลย tag<T>(value: T): Tagged<T> ก็เขียน T แค่ครั้งเดียวใน parameter เหมือนกัน แต่ T กลับมาปรากฏใน return type ด้วย ดังนั้นถ้าผู้เรียกใช้ t.value ผิด ก็ยังถูกจับได้ ให้นับ การเชื่อม ไม่ใช่นับจำนวนครั้งที่ปรากฏ
const type parameter (5.0)
หัวข้อที่มีชื่อว่า “const type parameter (5.0)”inference ผ่าน generic call widen literal แบบเดียวกับ let: first(["a", "b"]) infer T เป็น string ไม่ใช่ "a" | "b" ผู้เรียกเคยแก้ด้วย as const ทุกจุดที่เรียก ส่วน modifier const บน type parameter ขอให้ infer แบบ literal เป็นค่าเริ่มต้น:
function tuple<const T extends readonly unknown[]>(...items: T): T { return items;}
const t = tuple("a", 1, true);// ^? readonly ["a", 1, true] — without const: [string, number, boolean], same tuple shape, literals widenedconstraint มักต้องมี readonly (literal ที่ infer ได้เป็น readonly tuple หรือ object) ใช้กับ API ที่อิง literal value — route table, config แบบ enum, builder — ส่วน generic ทั่วไปปล่อยไว้ตามเดิม
const T ก็ยัง widen — มันไม่ใช่ as const ย้อนหลัง
หัวข้อที่มีชื่อว่า “const T ก็ยัง widen — มันไม่ใช่ as const ย้อนหลัง”const บน type parameter เปลี่ยนแค่วิธี infer argument ของ call นั้นเอง มันย้อนกลับไปทำให้ value ที่ widen ไปแล้วที่อื่นก่อนมาถึงมันกลับเป็น literal ไม่ได้:
function firstOf<const T>(items: T[]): T { return items[0];}
const arr = ["a", "b"]; // arr is already string[] — an ordinary mutable array, not a tupleconst f = firstOf(arr);// ^? string — NOT "a" | "b": arr was widened at its own declaration, long before firstOf saw itconst บน T ของ tuple ทำงานได้เพราะ rest parameter (...items: T) จับแต่ละ argument เป็น literal ของตัวเองตั้งแต่ตอนเรียก ก่อนที่อะไรจะ widen มัน ส่วน T ของ firstOf ถูก infer จาก element type ของ arr และ arr เป็น string[] ไปแล้วตั้งแต่ก่อนมาถึงตรงนั้น — ไม่เหลือ literal ให้ const รักษาไว้เลย const T รักษา literal ที่มันยังเห็นตอนมาถึง มันย้อนกลับไปแก้การ widen ที่เกิดไปแล้วก่อนหน้าไม่ได้
overload กับ generic
หัวข้อที่มีชื่อว่า “overload กับ generic”ทั้งสองแบบทำให้ชื่อเดียวรองรับหลายรูปแบบการเรียกได้ แต่แก้ปัญหาคนละแบบ generic เหมาะเมื่อสูตรเดียวกันใช้ได้สม่ำเสมอกับทุก type — T เข้า T (หรือสิ่งที่สร้างจาก T) ออก overload set เหมาะเมื่อคุณมีคู่ (input, output) ที่เจาะจงจำนวนน้อยและตายตัว ที่ไม่ยุบรวมเป็นสูตรเดียวได้
overload ยังให้ error ที่ชัดกว่า generic ที่เลือกไม่ดีได้ด้วย เทียบ function สองแบบที่ต้องรับ argument สองตัวที่ type เดียวกัน:
// @expect-error TS2345function both<T>(a: T, b: T): T { return a; }
const y = both(1, "two"); // the first argument fixes T to the literal 1, not number// @expect-error TS2769function bothOverload(a: string, b: string): string;function bothOverload(a: number, b: number): number;function bothOverload(a: unknown, b: unknown): unknown { return a; }
bothOverload(1, "two");A generic can give a worse error than an overload
// @expect-error TS2345 TS2769function both<T>(a: T, b: T): T { return a; }const y = both(1, "two");// TS2345: Argument of type '"two"' is not assignable to parameter of type '1'.// -- confusing: "type '1'"? T was inferred from the literal 1, not widened to number,// because nothing in this signature asks for the number the literal belongs to.
function bothOverload(a: string, b: string): string;function bothOverload(a: number, b: number): number;function bothOverload(a: unknown, b: unknown): unknown { return a; }bothOverload(1, "two");// TS2769: No overload matches this call. The last overload gave the following error.// Argument of type 'string' is not assignable to parameter of type 'number'.// -- clearer: it names the actual types, string and number, not a literal. กด Check types ข้างบน: error ของ both อ่านว่า Argument of type '"two"' is not assignable to parameter of type '1' — ผู้อ่านต้องหยุดคิดว่าทำไม type ของ parameter ถึงเป็น literal 1 ไม่ใช่ number มันเกิดเพราะ T ถูกตรึงไว้กับ literal type ของ argument แรกก่อนที่ argument ตัวที่สองจะถูกตรวจด้วยซ้ำ error ของ bothOverload คือ No overload matches this call. ... Argument of type 'string' is not assignable to parameter of type 'number' ระบุ type จริง ๆ ที่เกี่ยวข้อง ไม่มีข้อความไหน “ผิด” แต่ของ overload อ่านแล้วลงมือแก้ได้ทันทีง่ายกว่า — นี่คือ case ที่สัญชาตญาณ “generic ดีกว่า overload เสมอ” กลับตาลปัตร
ในทางกลับกันก็พบบ่อยเหมือนกัน: พอมี pairing มากกว่าสองสามคู่ หรือความสัมพันธ์ระหว่าง input กับ output สม่ำเสมอ (array เข้า array ของ element type เดียวกันออก) overload set จะกลายเป็นการซ้ำที่ดูแลไม่ไหว และ generic ก็เป็นตัวเลือกที่ถูกต้องชัดเจน — ทุก function ที่คุณเห็นมาก่อนหน้านี้ในบทนี้ (first, longest, getProp) ก็คือ case แบบนั้นเป๊ะ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”type parameter คือตัวแปรที่ “ค่า” ของมันคือ type ถูกแก้ครั้งเดียวต่อการเรียก จากตำแหน่งที่มันถูกเขียนไว้ ถาม T ที่คุณเจอสองคำถาม: มันมาจากไหน (argument ตำแหน่งไหนป้อนให้มัน) และ มันไปไหนต่อ (ตำแหน่งอื่น ๆ — parameter, return type — ที่มันไปจำกัดหลังจากถูก fix แล้ว)? ถ้าคำตอบของ “มันไปไหนต่อ” คือ “ไม่ไปไหนเลย” type parameter ตัวนั้นก็แค่ตกแต่ง ถ้า constraint ไม่ได้ถูกใช้จริงข้างใน body ก็ควรถามด้วยว่า constraint นั้นทำอะไรมากกว่าเป็นเอกสารประกอบหรือเปล่า
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
T ไม่มี constraint | ใช้ได้กับ type อะไรก็ได้จริง ๆ | สมมติ shape อะไรไม่ได้เลย — เข้าถึง property ไม่ได้ คำนวณเลขไม่ได้ |
T extends { shape } | ให้ body ใช้ shape นั้นได้ พร้อมยังคง type เฉพาะเจาะจงใน return | type ของทุกผู้เรียกต้องตรง constraint ซึ่งอาจปฏิเสธ value ที่ใช้ได้จริงแต่ไม่มี shape นั้นเป๊ะ |
const T | รักษา shape แบบ literal/tuple ได้โดยไม่ต้องใส่ as const ทุกจุดเรียก | ช่วยได้แค่ตอนที่ literal ยังมองเห็นได้ตอน call นั้น — ย้อนแก้ widen ที่เกิดไปแล้วไม่ได้ |
| overload set | error message ชัดเจนต่อ case, บันทึก pairing ที่ใช้ได้แบบตายตัว | ขยายไม่ไหว — combination ใหม่แต่ละอันต้องเขียน signature เพิ่มและดูแลเอง |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- type parameter ที่ใช้ครั้งเดียวใน parameter list เป็นกลิ่นไม่ดีเสมอ เป็นกลิ่นไม่ดีก็ต่อเมื่อใช้ครั้งเดียวใน ทั้ง signature ถ้ามันปรากฏใน return type ด้วย ก็คือกำลังเชื่อม input กับ output อยู่ — นั่นคือ generic จริง
- error message ของ generic function ชัดกว่า overload เสมอ ไม่เสมอไป — generic ตรึง type parameter ไว้กับ literal type แคบ ๆ ได้ตั้งแต่ต้น ทำให้เกิด message แบบ “not assignable to type ‘1’” ที่งง ในขณะที่ overload set ที่เทียบเท่ากันจะบอกด้วย type ที่มีชื่อชัดเจน
constบน type parameter ย้อนกลับไปแก้ widen ของอะไรก็ได้ที่คุณส่งเข้ามา ไม่ว่ามันจะมาจากไหน มันจับได้แค่ literal ที่ยังเป็น literal อยู่ตอน call site value ที่ถูก widen ไปแล้วโดยletdeclaration ของตัวมันเอง หรืออ่านออกมาจาก array ที่ widen ไปแล้ว จะมาถึง generic call แบบ widen ไปก่อนแล้ว และconstก็ไม่เหลืออะไรให้รักษา
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”generic ที่มีอยู่แค่เพื่อห่อ cast เป็นรูปแบบที่ AI generate บ่อยมาก — มันดูเหมือน type safety แต่ type parameter ไม่มี constraint และไม่เคยถูกตรวจกับอะไรจริงเลย:
- มองหา generic helper ที่ body คือ
return value as T(หรือas unknown as T) โดยTไม่มี constraint สิ่งนี้ compile ผ่านสำหรับTอะไรก็ได้ ที่ผู้เรียกตั้งชื่อ เหมือนascast ธรรมดาเป๊ะ — generic ไม่ได้เพิ่มอะไรนอกจากรูปลักษณ์ว่า “type” value นั้นแล้ว - เช็คว่า function เคย narrow, validate หรือตรวจสอบ value ก่อน return มันเป็น
Tหรือเปล่า ถ้าไม่เคย type จริงของ function ก็คือ(value: unknown) => anyไม่ว่า signature จะอ้างว่าอะไร - chain ของ helper แบบนี้สองตัว (ตัวแรก parse เป็น
unknownตัวถัดไป castunknownเป็นT) มีปัญหาเดียวกับ cast ตัวเดียว แค่กระจายเป็นสอง call ที่แต่ละอันดูสมเหตุสมผลเมื่อมองแยกกัน
🐛 หา bug ในโค้ดนี้
interface Config { retries: number; timeout: number }
function assertType<T>(value: unknown): T {return value as T;}
function loadConfig(raw: unknown): Config {return assertType<Config>(raw);}
const cfg = loadConfig({ retries: 3 });console.log(cfg.timeout.toFixed(0));เฉลย
โค้ดนี้ compile ผ่านโดยไม่มี error เลย assertType<T> ไม่มี constraint และ body ของมันคือ value as T เปล่า ๆ — มันจะ “อ้างสำเร็จ” ว่าเป็น shape อะไรก็ได้ สำหรับ input อะไรก็ได้ เพราะ as จาก unknown ไปเป็น type ที่เจาะจงได้รับอนุญาตเสมอโดยไม่มี runtime check แนบมาด้วย loadConfig({ retries: 3 }) ไม่มี field timeout เลย แต่ assertType<Config>(raw) ก็ยัง assert มันเป็น Config อยู่ดี ดังนั้น return type ของ loadConfig จึงโกหกเรื่องสิ่งที่มัน validate จริง เรียก cfg.timeout.toFixed(0) แล้วพังด้วย Cannot read properties of undefined (reading 'toFixed') เพราะ cfg.timeout เป็น undefined จริง ๆ ตอนรัน — generic ไม่เคยตรวจเลย ทางแก้เหมือนธีมของบท index ในโมดูลนี้: type parameter ของ generic น่าเชื่อถือได้แค่เท่าที่มี runtime check จริง ๆ อยู่ที่ไหนสักที่ใน chain — เปลี่ยน cast เป็น validation จริง (schema library หรือเช็ค field ตรง ๆ) ก่อนเชื่อ shape นั้น