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

Generic Functions & Constraints

ส่วนใหญ่คุณไม่ต้องเขียน 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 number
const b = first(["x", "y"]);
// ^? string | undefined — T inferred as string

คุณ ส่ง type argument ตรง ๆ ได้ (first<number>([1, 2, 3])) แต่แทบไม่จำเป็น type parameter ไหลมาจากจุดที่เรียก

T ที่ไม่มี constraint เป็นอะไรก็ได้ ดังนั้นคุณจะสมมติว่า property อะไรไม่ได้เลย เมื่อ function ต้องการให้ T มี shape บางอย่าง ให้ constrain ด้วย extends:

// @expect-error TS2339 TS2345
// ❌ without a constraint, T might not have .length
function 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 length
function 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 string
longest(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 อ้างถึง type parameter อีกตัวได้ ตัวอย่างคลาสสิกคือ get ที่ type-safe:

// @expect-error TS2345
function 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");
// ^? string
const age = getProp(user, "age");
// ^? number
getProp(user, "email"); // ❌ "email" is not a key of user

K extends keyof T บอกว่า key ต้องเป็นหนึ่งใน key จริง ๆ ของ T และ return type T[K] คือ type ของ property ตัวนั้นเป๊ะ ๆ นี่คือกระดูกสันหลังของ utility เกี่ยวกับ object ที่ type-safe

เหมือน default argument ของ function — type parameter มี default ได้ ซึ่งจะถูกใช้เมื่อ infer ไม่ได้และไม่ได้ส่งมา:

interface Container<T = string> {
value: T;
}
const c1: Container = { value: "hi" }; // T defaults to string
const c2: Container<number> = { value: 1 }; // T explicitly number

default พบบ่อยกับ generic class และ interface ที่มีค่า fallback ที่สมเหตุสมผล

อย่า generic เกินจำเป็น — แต่ “ใช้แค่ครั้งเดียว” ต้องแยกละเอียด

หัวข้อที่มีชื่อว่า “อย่า generic เกินจำเป็น — แต่ “ใช้แค่ครั้งเดียว” ต้องแยกละเอียด”

generics เป็นเครื่องมือ ไม่ใช่เป้าหมาย type parameter ที่ปรากฏ แค่ครั้งเดียวในทั้ง signature และไม่ปรากฏที่อื่นเลย มักเป็นความผิดพลาด — ไม่ได้เชื่อมอะไรเลย ก็เลยควรเป็น type ตรง ๆ หรือ unknown ไปเลย:

// pointless generic: T is used once, links nothing
function 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 TS2339
interface 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 ผิด ก็ยังถูกจับได้ ให้นับ การเชื่อม ไม่ใช่นับจำนวนครั้งที่ปรากฏ

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 widened

constraint มักต้องมี readonly (literal ที่ infer ได้เป็น readonly tuple หรือ object) ใช้กับ API ที่อิง literal value — route table, config แบบ enum, builder — ส่วน generic ทั่วไปปล่อยไว้ตามเดิม

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 tuple
const f = firstOf(arr);
// ^? string — NOT "a" | "b": arr was widened at its own declaration, long before firstOf saw it

const บน T ของ tuple ทำงานได้เพราะ rest parameter (...items: T) จับแต่ละ argument เป็น literal ของตัวเองตั้งแต่ตอนเรียก ก่อนที่อะไรจะ widen มัน ส่วน T ของ firstOf ถูก infer จาก element type ของ arr และ arr เป็น string[] ไปแล้วตั้งแต่ก่อนมาถึงตรงนั้น — ไม่เหลือ literal ให้ const รักษาไว้เลย const T รักษา literal ที่มันยังเห็นตอนมาถึง มันย้อนกลับไปแก้การ widen ที่เกิดไปแล้วก่อนหน้าไม่ได้

ทั้งสองแบบทำให้ชื่อเดียวรองรับหลายรูปแบบการเรียกได้ แต่แก้ปัญหาคนละแบบ generic เหมาะเมื่อสูตรเดียวกันใช้ได้สม่ำเสมอกับทุก type — T เข้า T (หรือสิ่งที่สร้างจาก T) ออก overload set เหมาะเมื่อคุณมีคู่ (input, output) ที่เจาะจงจำนวนน้อยและตายตัว ที่ไม่ยุบรวมเป็นสูตรเดียวได้

overload ยังให้ error ที่ชัดกว่า generic ที่เลือกไม่ดีได้ด้วย เทียบ function สองแบบที่ต้องรับ argument สองตัวที่ type เดียวกัน:

// @expect-error TS2345
function 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 TS2769
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");

A generic can give a worse error than an overload

// @expect-error TS2345 TS2769
function 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.
Open in TS Playground

กด 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 แบบนั้นเป๊ะ

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 เฉพาะเจาะจงใน returntype ของทุกผู้เรียกต้องตรง constraint ซึ่งอาจปฏิเสธ value ที่ใช้ได้จริงแต่ไม่มี shape นั้นเป๊ะ
const Tรักษา shape แบบ literal/tuple ได้โดยไม่ต้องใส่ as const ทุกจุดเรียกช่วยได้แค่ตอนที่ literal ยังมองเห็นได้ตอน call นั้น — ย้อนแก้ widen ที่เกิดไปแล้วไม่ได้
overload seterror 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 ไปแล้วโดย let declaration ของตัวมันเอง หรืออ่านออกมาจาก array ที่ widen ไปแล้ว จะมาถึง generic call แบบ widen ไปก่อนแล้ว และ const ก็ไม่เหลืออะไรให้รักษา

generic ที่มีอยู่แค่เพื่อห่อ cast เป็นรูปแบบที่ AI generate บ่อยมาก — มันดูเหมือน type safety แต่ type parameter ไม่มี constraint และไม่เคยถูกตรวจกับอะไรจริงเลย:

  • มองหา generic helper ที่ body คือ return value as T (หรือ as unknown as T) โดย T ไม่มี constraint สิ่งนี้ compile ผ่านสำหรับ T อะไรก็ได้ ที่ผู้เรียกตั้งชื่อ เหมือน as cast ธรรมดาเป๊ะ — generic ไม่ได้เพิ่มอะไรนอกจากรูปลักษณ์ว่า “type” value นั้นแล้ว
  • เช็คว่า function เคย narrow, validate หรือตรวจสอบ value ก่อน return มันเป็น T หรือเปล่า ถ้าไม่เคย type จริงของ function ก็คือ (value: unknown) => any ไม่ว่า signature จะอ้างว่าอะไร
  • chain ของ helper แบบนี้สองตัว (ตัวแรก parse เป็น unknown ตัวถัดไป cast unknown เป็น 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 นั้น

ทำไมต้อง constrain type parameter ด้วย extends?
ใน function getProp<T, K extends keyof T>(obj: T, key: K): T[K] return type T[K] แทนอะไร?
function tag<T>(value: T): Tagged<T> (โดย Tagged<T> = { value: T }) เป็นกลิ่น "ใช้ครั้งเดียว" หรือเปล่า?
error ที่ได้บอกว่าอะไร และทำไมมันงง?
function both<T>(a: T, b: T): T { return a; }
const y = both(1, "two");
เทียบกับ generic both<T> ข้างบน ทำไม overload set ถึงให้ error ที่ชัดกว่าสำหรับการเรียกแบบเดียวกัน?
generic helper function firstOf<const T>(items: T[]): T ถูกเรียกด้วย firstOf(arr) โดย const arr = ["a", "b"] firstOf คืน type อะไร และทำไม?