TypeScript generics ช่วยให้เราเขียน function, class หรือ type ครั้งเดียวแล้วใช้ซ้ำกับหลาย type ได้ โดยไม่ต้องถอยไปใช้ any เราประกาศ type parameter อย่าง <T> แล้ว compiler จะเติม type จริงให้จากจุดที่เรียกใช้ เช่น first([1, 2, 3]) ได้ number กลับมา ส่วน first(["a"]) ได้ string ส่วน conditional types คือแนวคิดเดียวกันในระดับ type คือเลือก type หนึ่งหรืออีก type หนึ่งตามเงื่อนไข และเมื่อใช้คู่กับ infer ก็ดึง type ที่ซ้อนอยู่ข้างในออกมาได้
บทความนี้เริ่มจาก class ก่อน เพราะ generic class ต่อยอดมาจากตรงนั้น จากนั้นจึงไล่ไปที่ generics, constraint, conditional types, infer และ recursion ตัวอย่างทั้งหมดใช้ TypeScript 5.x และเปิด strict
TypeScript class: พื้นฐานก่อนเขียน generic
Field, constructor และ method
class ประกาศ field พร้อม type, กำหนดค่าใน constructor และมี method ที่ใช้ข้อมูลเหล่านั้น:
class Person {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
print(): void {
console.log("name:", this.name);
}
}
const somchai = new Person("Somchai", 18);
const somsri = new Person("Somsri", 20);
somchai.print(); // name: Somchaiเมื่อเปิด strict ทุก field ต้องถูกกำหนดค่าใน constructor หรือมีค่า default ไม่อย่างนั้นจะ compile ไม่ผ่าน object จึงไม่มีทางถูกสร้างขึ้นมาแบบครึ่ง ๆ กลาง ๆ
Inheritance ด้วย extends และ super
subclass ใช้ extends และต้องเรียก super(...) ก่อนแตะ this:
class Employee extends Person {
department: string;
constructor(name: string, age: number, department: string) {
super(name, age);
this.department = department;
}
override print(): void {
super.print();
console.log("department:", this.department);
}
}ถ้าใส่ override ให้ method ที่ parent ไม่มี จะ error ทันที ช่วยจับกรณีที่ method ใน parent ถูกเปลี่ยนชื่อ และถ้าเปิด noImplicitOverride การ override โดยไม่เขียน keyword นี้ก็จะ error ด้วย
Access modifier: public, private, protected
| Modifier | เข้าถึงได้จาก | บังคับตอน runtime ไหม |
|---|---|---|
public (ค่าเริ่มต้น) | ทุกที่ | ไม่มีข้อจำกัด |
protected | ตัว class และ subclass | ไม่ ตรวจแค่ตอน compile |
private | ตัว class เท่านั้น | ไม่ ตรวจแค่ตอน compile |
#field (ECMAScript private) | ตัว class เท่านั้น | ใช่ |
readonly | อ่านได้ทุกที่ที่มองเห็น กำหนดค่าได้แค่ตอนประกาศหรือใน constructor | ไม่ |
class BankAccount {
readonly id: string;
protected balance = 0;
#pin: string;
constructor(id: string, pin: string) {
this.id = id;
this.#pin = pin;
}
}private และ protected หายไปหลัง compile ใครที่ถือ reference ของ object อยู่ก็ยังอ่านค่าได้จาก JavaScript ธรรมดา ถ้าต้องซ่อนค่าจริง ๆ ตอน runtime ให้ใช้ # field
Parameter properties
Parameter properties เป็นทางลัด แค่ใส่ modifier หน้า parameter ของ constructor แล้ว TypeScript จะประกาศ field และกำหนดค่าให้เอง:
class Product {
constructor(
public readonly sku: string,
public name: string,
private price: number,
) {}
priceWithVat(): number {
return this.price * 1.07;
}
}ข้อควรระวังคือ parameter properties ทำให้เกิดโค้ด JavaScript เพิ่ม จึงใช้กับ type stripping ที่มากับ Node.js ไม่ได้ และ option erasableSyntaxOnly (TypeScript 5.8 ขึ้นไป) จะแจ้ง error ถ้าโปรเจกต์ให้ Node รันไฟล์ .ts ตรง ๆ ให้เขียน field ออกมาเต็ม ๆ แทน
Abstract class
abstract class สร้าง instance โดยตรงไม่ได้ มันกำหนดพฤติกรรมที่ใช้ร่วมกัน และปล่อยบาง member ไว้ให้ subclass implement:
abstract class Shape {
abstract area(): number;
describe(): string {
return `${this.constructor.name} with area ${this.area().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(private radius: number) {
super();
}
area(): number {
return Math.PI * this.radius ** 2;
}
}
// new Shape(); // Error: Cannot create an instance of an abstract class.
console.log(new Circle(2).describe());ใช้ abstract class เมื่อ subclass มี implementation ที่ใช้ร่วมกันจริง ๆ ถ้าต้องการแค่ contract ใช้ interface จะเบากว่า ส่วนจะเลือก interface หรือ type alias อ่านต่อได้ที่ TypeScript interface vs type alias
TypeScript generics ใน function และ class
Generic function
ถ้าไม่มี generics เราต้องเขียน function ซ้ำทีละ type หรือไม่ก็รับ any แล้วเสียข้อมูล type ไป type parameter ช่วยผูก input กับ output ไว้ด้วยกัน:
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([10, 20, 30]); // number | undefined
const s = first(["a", "b"]); // string | undefined
const u = first<string>([]); // explicit type argumentส่วนใหญ่ไม่ต้องใส่ type argument เอง TypeScript จะ infer T จาก argument ให้ ใส่เองก็ต่อเมื่อไม่มีข้อมูลให้ infer เช่น array ว่างในตัวอย่าง
Generic class
generic class เหมาะกับ container และ repository ที่ logic เหมือนกันทุก type:
class Stack<T> {
#items: T[] = [];
push(item: T): void {
this.#items.push(item);
}
pop(): T | undefined {
return this.#items.pop();
}
get size(): number {
return this.#items.length;
}
}
const numbers = new Stack<number>();
numbers.push(1);
// numbers.push("2"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.Generic type alias และ interface
type ก็รับ parameter ได้ ตัวอย่างที่เจอบ่อยคือ wrapper ของ API response:
interface ApiResponse<T> {
data: T;
error: string | null;
}
type User = { id: number; email: string };
async function getUsers(): Promise<ApiResponse<User[]>> {
const res = await fetch("/api/users");
return res.json();
}type parameter มีค่า default ได้ด้วย เช่น interface ApiResponse<T = unknown> คนที่ไม่สนใจ type ของ payload ก็เขียนแค่ ApiResponse ได้เลย
Generic constraint ด้วย extends
T ที่ไม่มี constraint จะเป็นอะไรก็ได้ เราจึงเข้าถึง property ของมันไม่ได้ constraint คือการบอกว่า T อย่างน้อยต้องมีอะไร:
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest("apple", "kiwi"); // OK, string has length
longest([1, 2], [1, 2, 3]); // OK, arrays have length
// longest(10, 20); // Error: number has no 'length'constraint ที่ใช้บ่อยที่สุดคือใช้ keyof ผูก type parameter ตัวหนึ่งเข้ากับอีกตัว:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, email: "[email protected]" };
getProperty(user, "email"); // string
// getProperty(user, "name"); // Error: '"name"' is not assignable to '"id" | "email"'return type T[K] เป็น indexed access type ผลลัพธ์จึงเป็น type ของ property ที่ขอพอดี เรื่อง keyof และ indexed access อ่านละเอียดได้ใน TypeScript advanced types: union, keyof และ mapped types
Conditional types: T extends U ? X : Y
conditional type ทำงานเหมือน ternary แต่ทำกับ type ถ้า T assign ให้ U ได้ ผลลัพธ์คือ X ไม่อย่างนั้นคือ Y:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // falseการกระจายตัวบน union (distributive)
ถ้า type ที่ถูกเช็กเป็น type parameter เปล่า ๆ แล้วเราส่ง union เข้าไป เงื่อนไขจะถูกเช็กทีละสมาชิก แล้วรวมผลเป็น union อีกครั้ง คุณสมบัตินี้ทำให้เรากรอง type ได้:
type NumberOrString = string[] | number[] | number | string | undefined;
type ArrayFilter<T> = T extends unknown[] ? T : never;
type ArrayOnly = ArrayFilter<NumberOrString>;
// string[] | number[] | never | never | never
// => string[] | number[]never หายไปเองเมื่ออยู่ใน union จึงเหลือแค่สมาชิกที่เป็น array utility type อย่าง Exclude และ Extract ก็ทำงานแบบนี้
บางครั้งเราไม่ต้องการให้กระจาย ให้ครอบทั้งสองฝั่งด้วย tuple:
type IsStringWhole<T> = [T] extends [string] ? true : false;
type C = IsString<string | number>; // boolean (true | false)
type D = IsStringWhole<string | number>; // falseดึง type ออกมาด้วย infer
ในส่วน extends ของ conditional type เราใช้ infer ประกาศตัวแปร type ใหม่ แล้วให้ TypeScript หาเองว่ามันคืออะไร:
type ElementType<T> = T extends readonly (infer U)[] ? U : never;
type E1 = ElementType<string[]>; // string
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type E2 = UnwrapPromise<Promise<number>>; // number
type E3 = UnwrapPromise<boolean>; // boolean
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;
type E4 = FirstArg<(id: number, name: string) => void>; // numberReturnType และ Parameters ที่มากับภาษาก็เขียนด้วยวิธีนี้ ตั้งแต่ TypeScript 4.7 เราใส่ constraint ให้ตัวที่ infer ได้โดยตรง เช่น infer U extends string ไม่ต้องเขียน conditional ซ้อนอีกชั้น และ infer ใช้ใน template literal type ได้ด้วย เช่น S extends `${infer Head}/${string}` ดึง segment แรกของ path ออกมาได้
Recursive conditional types
conditional type อ้างถึงตัวเองได้ ใช้จัดการโครงสร้างที่ไม่รู้ความลึก เช่น array ซ้อนกันหลายชั้น:
type Flatten<T> = T extends readonly (infer U)[] ? Flatten<U> : T;
type F1 = Flatten<number[][][]>; // number
type F2 = Flatten<string>; // stringrecursion ใช้กับ tuple ได้เหมือนกัน type นี้ต่อ string ใน tuple ด้วยตัวคั่น:
type Join<T extends string[], Sep extends string> =
T extends [infer Head extends string, ...infer Rest extends string[]]
? Rest extends []
? Head
: `${Head}${Sep}${Join<Rest, Sep>}`
: "";
type Path = Join<["api", "v1", "users"], "/">; // "api/v1/users"Awaited<T> ที่มากับภาษาก็เป็น recursive type มันแกะ Promise<Promise<string>> ลงไปจนเหลือ string
compiler จำกัดความลึกของ recursion ไว้ ถ้าลึกเกินจะเจอ error "Type instantiation is excessively deep and possibly infinite." conditional type ที่เป็น tail recursion คือเรียกตัวเองเป็นผลลัพธ์ทั้งหมดของ branch แบบ Flatten จะได้ขีดจำกัดสูงกว่าแบบที่ห่อการเรียกไว้ข้างในอย่าง Join มาก ถ้าชนขีดจำกัด ส่วนใหญ่การเช็กตอน runtime เป็นเครื่องมือที่เหมาะกว่า
ข้อผิดพลาดที่พบบ่อยกับ TypeScript generics
- type parameter ที่ใช้แค่ที่เดียว ใน
function log<T>(value: T): voidตัวTไม่ได้เชื่อมอะไรเลย เขียนvalue: unknownก็ได้ความหมายเดียวกัน type parameter ควรผูกอย่างน้อยสองตำแหน่ง เช่น input กับ output - ใช้ cast แทน constraint ถ้าเห็นตัวเองเขียน
(obj as any).lengthแปลว่าต้องการ constraint อย่างT extends { length: number } - เขียน type-level logic ลึก ๆ ในโค้ด feature conditional และ recursive type เหมาะกับ helper ที่ใช้ร่วมกัน ส่วน business logic ใช้ interface ธรรมดาจะอ่านง่ายกว่า
คำถามที่พบบ่อย
generics ต่างจาก any อย่างไร
any ปิดการตรวจ type ทำให้ output ไม่มีความเชื่อมโยงกับ input อีกต่อไป ส่วน generic T จะถูกเติมด้วย type จริงทุกครั้งที่เรียกใช้ เราจึงยังได้ทั้งการตรวจ type และ autocomplete ครบ
ควรใส่ generic constraint เมื่อไร
ใส่ extends ทันทีที่ใน function ต้องใช้อะไรบางอย่างจาก T เช่น property, method หรือ key ตัวอย่าง T extends { id: string } ทำให้อ่าน item.id ได้ และ K extends keyof T รับประกันว่า key มีอยู่จริงใน object
infer ใน TypeScript ทำอะไร
infer ประกาศตัวแปร type ในส่วน extends ของ conditional type แล้วให้ TypeScript เติมค่าจาก type ที่ match เป็นวิธีดึง type ของสมาชิกใน array หรือ return type ของ function ออกมา
ทำไม conditional type ถึงคืนค่าเป็น union อย่าง boolean
เพราะเงื่อนไขเป็นแบบ distributive union ที่ส่งเข้า type parameter เปล่า ๆ จะถูกเช็กทีละสมาชิก ถ้าต้องการเช็ก union ทั้งก้อน ให้ครอบด้วย tuple เป็น [T] extends [U]
สรุป
- ใช้
#fieldเมื่อต้องการความเป็น private จริงตอน runtime และเลี่ยง parameter properties ถ้าพึ่ง type stripping ของ Node - ใช้ generics เมื่อโค้ดทำงานแบบเดียวกันกับหลาย type และปล่อยให้ inference เติม type argument ให้
- ใส่ constraint ด้วย
extendsและkeyofทันทีที่ต้องรู้อะไรบางอย่างเกี่ยวกับT - ใช้ conditional type และ
inferเพื่อสร้าง type จาก type อื่น และจำไว้ว่ามันกระจายตัวบน union - ทำ recursive type ให้เล็กและตั้งชื่อให้สื่อความหมาย
helper ใน คู่มือ TypeScript utility types สร้างขึ้นจากชิ้นส่วนเดียวกันทั้งหมด การอ่าน definition ของมันจึงเป็นแบบฝึกหัดที่ดี ถ้าทีมของคุณกำลังสร้าง web application ด้วย TypeScript และอยากมีคนช่วยดู architecture อีกแรง Vectorkub ทำงานลักษณะนี้อยู่
