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

Frontend

TypeScript Interface vs Type: เลือกให้ถูก พร้อม Enum, Tuple, Overload และ Type Guard

TypeScript interface vs type ควรใช้แบบไหนเมื่อไร พร้อมเรื่อง array กับ tuple, enum และทางเลือกอื่น, excess property check, function type, overload และ type guard พร้อมตัวอย่าง

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

typescript interfaces vs type aliases

คำถาม TypeScript interface vs type มีคำตอบสั้น ๆ คือทั้งสองใช้อธิบาย shape ของ object ได้ และสำหรับ object type ส่วนใหญ่ใช้ตัวไหนก็ได้ interface เปิดประกาศซ้ำเพื่อ merge ได้ และต่อยอดด้วย extends ส่วน type alias ตั้งชื่อให้ type ได้ทุกแบบ รวมถึง union, tuple, primitive และ mapped type หลักที่ใช้ได้จริงคือ ใช้ interface กับ shape ของ object โดยเฉพาะที่เป็น public และใช้ type กับอย่างอื่นทั้งหมด เลือกแบบไหนก็ได้ ขอแค่ใช้ให้สม่ำเสมอ

บทความนี้อธิบายการเลือกนี้ไปพร้อมกับ compound type อื่นที่ใช้กันทุกวัน ได้แก่ array และ tuple, enum, excess property check, function type, overload และ type guard ตัวอย่างใช้ TypeScript 5.x

Array และ tuple

Array เก็บสมาชิกกี่ตัวก็ได้ที่เป็น type เดียวกัน เขียนได้สองแบบซึ่งมีความหมายเท่ากัน:

ts
const tags: string[] = ["ts", "node"];
const scores: Array<number> = [90, 85];

const tenth = tags[10]; // typed as string, but undefined at runtime

การอ่าน index ที่เกินขนาด array จะได้ undefined ตอน runtime แต่ TypeScript ยังให้ type เป็น string ถ้าต้องการให้การอ่าน index ได้ type เป็น string | undefined ให้เปิด noUncheckedIndexedAccess

Tuple คือ array ที่ความยาวตายตัวและรู้ type ของแต่ละตำแหน่ง:

ts
let point: [number, number] = [10, 20];
point = [10, 20, 30]; // Error: source has 3 elements but target allows only 2
const z = point[2];   // Error: tuple of length 2 has no element at index 2

Labeled tuple ช่วยบอกความหมายของแต่ละตำแหน่ง และรองรับสมาชิกแบบ optional และ rest:

ts
type Range = [start: number, end: number];
type HttpResult = [status: number, body: string, headers?: Record<string, string>];
type Path = [root: string, ...segments: string[]];

มีจุดที่ต้องระวังคือ tuple ที่แก้ไขได้ยังมีเมธอดของ array อยู่ point.push(30) จึง compile ผ่านและทำให้ความยาวไม่ตายตัวอีกต่อไป ถ้า tuple ไม่ควรเปลี่ยน ให้ใส่ readonly:

ts
const origin: readonly [number, number] = [0, 0];
origin.push(1); // Error: property 'push' does not exist

ใช้ tuple กับข้อมูลสั้น ๆ ที่อิงตำแหน่ง เช่นพิกัด หรือค่าที่ hook คืนกลับ ถ้าเกินสาม field ไปแล้ว object ที่มีชื่อ field จะอ่านง่ายกว่า

Enum และทางเลือกอื่น

Enum ใช้นิยามชุดค่าคงที่ที่มีชื่อ numeric enum จะนับเพิ่มอัตโนมัติจาก 0 และมี reverse mapping ให้:

ts
enum Direction { Up, Down, Left, Right }

Direction.Up; // 0
Direction[0]; // "Up"

String enum อ่านง่ายกว่าเมื่อไปโผล่ใน log หรือ payload ของ API:

ts
enum OrderStatus {
  Pending = "PENDING",
  Paid = "PAID",
  Shipped = "SHIPPED",
}

function ship(status: OrderStatus) { /* ... */ }
ship(OrderStatus.Paid); // OK
ship("PAID");           // Error: '"PAID"' is not assignable to 'OrderStatus'

Enum เป็นหนึ่งในไม่กี่ฟีเจอร์ของ TypeScript ที่สร้างโค้ดตอน runtime จึงใช้ไม่ได้กับการตั้งค่าที่รองรับแค่ syntax ที่ลบทิ้งได้ เช่น type stripping ที่มีในตัว Node.js หรือออปชัน erasableSyntaxOnly ที่เพิ่มมาใน TypeScript 5.8 หลายทีมจึงเลือกใช้ union ของ literal หรือ object แบบ as const ซึ่งได้ autocomplete เหมือนกัน แต่ข้างใต้เป็น JavaScript ธรรมดา:

ts
const OrderState = {
  Pending: "PENDING",
  Paid: "PAID",
  Shipped: "SHIPPED",
} as const;

type OrderState = (typeof OrderState)[keyof typeof OrderState];
// "PENDING" | "PAID" | "SHIPPED"

function ship2(status: OrderState) { /* ... */ }
ship2("PAID");           // OK
ship2(OrderState.Paid);  // OK

Interface

Interface ใช้ตั้งชื่อให้ shape ของ object property เป็น optional (?) หรือ readonly ได้:

ts
interface User {
  readonly id: string;
  name: string;
  email?: string;
}

interface Admin extends User {
  permissions: string[];
}

interface Timestamped {
  createdAt: Date;
}

interface AuditedAdmin extends Admin, Timestamped {}

Interface หนึ่งตัว extend ได้หลายตัว และ class สามารถ implements interface เพื่อให้ compiler ตรวจว่ามีสมาชิกครบ ส่วน readonly เป็นการตรวจตอน compile เท่านั้น ไม่ได้ freeze object ตอน runtime

Type alias

Type alias ตั้งชื่อให้ type อะไรก็ได้ ไม่จำกัดแค่ shape ของ object:

ts
type ID = string | number;                       // union
type Point = { x: number; y: number };           // object
type Pair = [key: string, value: number];        // tuple
type Handler = (event: MouseEvent) => void;      // function
type AdminUser = User & { permissions: string[] }; // intersection

Union, tuple, function type, mapped type และ conditional type เขียนได้ด้วย type เท่านั้น รูปแบบขั้นสูงอย่าง mapped type อธิบายไว้ใน TypeScript advanced types

TypeScript interface vs type: ความต่างที่มีผลจริง

interfacetype
Shape ของ objectได้ได้
Union, tuple, primitive, function typeไม่ได้ได้
Mapped และ conditional typeไม่ได้ได้
การต่อยอดextends ถ้าขัดกันจะ error& ถ้าขัดกันจะกลายเป็น never เงียบ ๆ
Declaration mergingได้ ชื่อเดียวกันจะ merge กันไม่ได้ error duplicate identifier
ใช้กับ implements ใน classได้ได้ ถ้าเป็น object type (ไม่ใช่ union)
ชื่อใน error message และ hoverแสดงเป็นชื่อบางครั้งถูกขยายเป็นโครงสร้างเต็ม

มีสองแถวที่ควรดูตัวอย่าง

Declaration merging interface ชื่อเดียวกันใน scope เดียวกันจะถูกรวมเป็นตัวเดียว ส่วน type alias ประกาศซ้ำไม่ได้:

ts
interface Settings { theme: string }
interface Settings { language: string }

const s: Settings = { theme: "dark", language: "th" }; // needs both

type Options = { theme: string };
type Options = { language: string }; // Error: duplicate identifier 'Options'

การ merge คือวิธีเพิ่ม field ให้ type ของ third-party เช่นเพิ่ม property user ให้ Request ของ Express ตามที่อธิบายใน TypeScript declaration files ข้อเสียคือ interface อาจถูกต่อเติมโดยไม่ตั้งใจ ถ้าไฟล์อื่นใช้ชื่อ global เดียวกัน

ความขัดแย้งตอนต่อยอด extends ตรวจความเข้ากันได้ทันที แต่ intersection ไม่ตรวจ:

ts
interface A { id: string }
interface B extends A { id: number }
// Error: interface 'B' incorrectly extends interface 'A'

type C = A & { id: number }; // no error, but C["id"] is never

คำแนะนำด้าน performance ของ TypeScript ก็แนะนำให้ใช้ extends แทน intersection ขนาดใหญ่ เพราะ compiler cache ความสัมพันธ์ระหว่าง interface ที่มีชื่อไว้ได้

หลักที่ใช้ได้จริง: ใช้ interface กับ shape ของ object ที่โค้ดส่วนอื่นจะต่อยอด เช่น props ของ component, model ของ API และ contract ของ library ใช้ type กับ union, tuple, function type และ type ที่คำนวณจาก type อื่น ถ้า codebase ของคุณใช้แนวทางอื่นอย่างสม่ำเสมออยู่แล้ว ก็ใช้ต่อไปได้

Excess property check

เมื่อ assign object literal ให้ตัวแปรหรือ parameter ที่มี type โดยตรง TypeScript จะปฏิเสธ property ที่ type ไม่ได้ประกาศไว้:

ts
interface Point { x: number; y: number }

const p: Point = { x: 1, y: 2, z: 3 };
// Error: object literal may only specify known properties, and 'z' does not exist in type 'Point'

ประโยชน์หลักคือจับการพิมพ์ชื่อ field optional ผิด ซึ่งปกติจะถูกเพิกเฉยไปเงียบ ๆ:

ts
function createUser(options: { name: string; email?: string }) { /* ... */ }

createUser({ name: "Ana", emial: "[email protected]" });
// Error: 'emial' does not exist. Did you mean to write 'email'?

การตรวจนี้ใช้กับ object literal ที่ เพิ่งสร้างสด ๆ เท่านั้น ถ้าเก็บ object ไว้ในตัวแปรก่อน property ส่วนเกินจะผ่านได้ เพราะ structural typing ต้องการแค่ให้มี property ที่ประกาศไว้ครบ:

ts
const raw = { x: 1, y: 2, z: 3 };
const p2: Point = raw; // OK

ถ้า type ควรรับ key เพิ่มเติมได้จริง ให้ประกาศ index signature

Function type

TypeScript ใส่ type ให้ parameter และค่าที่ return ได้ในฟังก์ชันทั้งสามรูปแบบของ JavaScript:

ts
// function declaration
function add(a: number, b: number): number {
  return a + b;
}

// anonymous function expression assigned to a variable
const multiply = function (a: number, b: number): number {
  return a * b;
};

// arrow function
const subtract = (a: number, b: number): number => a - b;

Parameter เป็น optional ได้ มีค่า default ได้ หรือรวบส่วนที่เหลือเป็น array ได้:

ts
function greet(name: string, greeting = "Hello", title?: string): string {
  return title ? `${greeting}, ${title} ${name}` : `${greeting}, ${name}`;
}

function sum(...values: number[]): number {
  return values.reduce((total, v) => total + v, 0);
}

ถ้าต้องการอธิบายฟังก์ชันเป็น type เช่นสำหรับ callback หรือ strategy ให้ใช้ function type expression:

ts
type Comparator<T> = (a: T, b: T) => number;

const byPrice: Comparator<Product> = (a, b) => a.price - b.price;
products.sort(byPrice);

Function overloading

Overload ทำให้ฟังก์ชันเดียวมีได้หลาย call signature แต่ละแบบมี return type ของตัวเอง เขียน overload signature ไว้ก่อน แล้วตามด้วย implementation ตัวเดียวที่รองรับทุกกรณี:

ts
function getUser(id: string): User;
function getUser(ids: string[]): User[];
function getUser(idOrIds: string | string[]): User | User[] {
  return Array.isArray(idOrIds) ? idOrIds.map(findById) : findById(idOrIds);
}

const one = getUser("u1");          // User
const many = getUser(["u1", "u2"]); // User[]

ผู้เรียกมองไม่เห็น implementation signature ถ้าผู้เรียกถือค่าเป็น string | string[] จะได้ error "No overload matches this call" ใช้ overload เฉพาะเมื่อ return type ขึ้นกับ argument จริง ๆ ไม่อย่างนั้น signature เดียวที่รับ union จะง่ายกว่า อีกทางเลือกคือ return type แบบ generic หรือ conditional ซึ่งอธิบายใน TypeScript generics

Type guard

Type guard คือการเช็กตอน runtime ที่ TypeScript ใช้ narrow type ตัวที่มีในภาษาได้แก่ typeof, instanceof, in, Array.isArray และการเทียบค่า:

ts
function describe(value: string | number | Date): string {
  if (typeof value === "string") return value.trim();
  if (value instanceof Date) return value.toISOString();
  return value.toFixed(2); // number
}

สำหรับ type ของคุณเอง ให้เขียนฟังก์ชันที่ return type เป็น type predicate แบบ value is Type:

ts
type Fish = { swim(): void };
type Bird = { fly(): void };

function isFish(pet: Fish | Bird): pet is Fish {
  return "swim" in pet;
}

const fish = pets.filter(isFish); // Fish[]

Assertion function จะ throw แทนการคืน boolean และ narrow ทุกอย่างหลังจากบรรทัดที่เรียก:

ts
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") throw new TypeError("Expected a string");
}

assertIsString(input);
input.toUpperCase(); // input is string from here on

ตั้งแต่ TypeScript 5.5 predicate แบบง่ายจะถูก infer ให้อัตโนมัติ ["a", undefined, "b"].filter((id) => id !== undefined) จึงได้ type เป็น string[] โดยไม่ต้องเขียน id is string เอง compiler เชื่อ predicate แบบไม่มีเงื่อนไข จึงควรเขียน guard ให้เล็กและมี test

คำถามที่พบบ่อย

ใน TypeScript ควรใช้ interface หรือ type?

ใช้ interface กับ shape ของ object โดยเฉพาะตัวที่ถูก extend หรือ export และใช้ type กับ union, tuple, function type และ type ที่คำนวณขึ้น สำหรับ object ธรรมดาใช้ได้ทั้งคู่ ความสม่ำเสมอสำคัญกว่าตัวเลือก

Class implement type alias ได้ไหม?

ได้ ตราบใดที่ alias นั้นเป็น object type หรือ intersection ของ object type แต่ class implement union type ไม่ได้

Enum ใน TypeScript ไม่ดีหรือเปล่า?

ไม่ถึงกับไม่ดี แต่มันสร้างโค้ดตอน runtime และมีพฤติกรรมต่างจาก type system ส่วนอื่น union ของ string literal หรือ object แบบ as const ครอบคลุมการใช้งานส่วนใหญ่ได้โดยไม่มีเรื่องให้แปลกใจ

ทำไมส่งตัวแปรเข้าไปแล้วไม่มี error เรื่อง property เกิน?

Excess property check ใช้กับ object literal ที่เขียนตรงจุดที่คาดหวัง type เท่านั้น ตัวแปรจะถูกตรวจแบบ structural จึงมี property เกินได้

Tuple ต่างจาก array อย่างไร?

Array มีความยาวเท่าไรก็ได้และสมาชิกเป็น type เดียว ส่วน tuple มีความยาวตายตัวและกำหนด type เฉพาะของแต่ละตำแหน่ง เช่น [number, string]

Checklist

  • ใช้ interface กับ shape ของ object และใช้ type กับ union, tuple และ function type
  • ใส่ readonly ให้ tuple และเปลี่ยนไปใช้ object เมื่อเกินสาม field
  • ลองใช้ literal union หรือ object แบบ as const ก่อนจะเลือก enum
  • ปล่อยให้ excess property check จับชื่อที่พิมพ์ผิด และใช้ index signature เมื่อ key เพิ่มเติมเป็นเรื่องปกติ
  • ใช้ overload เฉพาะเมื่อ return type ขึ้นกับ argument
  • เขียน type guard ให้เล็ก เพราะ compiler เชื่อมันทั้งหมด

ค่าที่ไม่รู้ type ซึ่งเป็นสิ่งที่ type guard มัก narrow อยู่บ่อย ๆ อ่านต่อได้ที่ any vs unknown ถ้าทีมของคุณอยากได้คนช่วยรีวิว codebase TypeScript หรือการออกแบบ type Vectorkub ช่วยได้

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

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

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