กลับไปยังบทความ

Frontend

TypeScript Generics: ตั้งแต่ Class, Constraint จนถึง Conditional Types และ infer

เรียนรู้ TypeScript generics ทีละขั้น ตั้งแต่ class และ access modifier, generic function และ generic class, constraint ด้วย keyof ไปจนถึง conditional types, infer และ recursive types พร้อมตัวอย่าง TypeScript 5.x

24 กันยายน 256910 นาทีในการอ่าน

typescript generics and conditional types

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 ที่ใช้ข้อมูลเหล่านั้น:

ts
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:

ts
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ไม่
ts
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 และกำหนดค่าให้เอง:

ts
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:

ts
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 ไว้ด้วยกัน:

ts
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:

ts
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:

ts
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 อย่างน้อยต้องมีอะไร:

ts
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 ตัวหนึ่งเข้ากับอีกตัว:

ts
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:

ts
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 ได้:

ts
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:

ts
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 หาเองว่ามันคืออะไร:

ts
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>; // number

ReturnType และ 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 ซ้อนกันหลายชั้น:

ts
type Flatten<T> = T extends readonly (infer U)[] ? Flatten<U> : T;

type F1 = Flatten<number[][][]>;   // number
type F2 = Flatten<string>;         // string

recursion ใช้กับ tuple ได้เหมือนกัน type นี้ต่อ string ใน tuple ด้วยตัวคั่น:

ts
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 ทำงานลักษณะนี้อยู่

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ