ไทย

สำรวจ Branded Types ใน TypeScript ซึ่งเป็นเทคนิคที่ทรงพลังในการสร้างการพิมพ์แบบระบุชื่อในระบบประเภทโครงสร้าง เรียนรู้วิธีเพิ่มความปลอดภัยของประเภทและความชัดเจนของโค้ด

Branded Types ใน TypeScript: การพิมพ์แบบระบุชื่อในระบบโครงสร้าง

ระบบประเภทแบบโครงสร้าง (structural type system) ของ TypeScript ให้ความยืดหยุ่น แต่บางครั้งอาจนำไปสู่พฤติกรรมที่ไม่คาดคิด Branded types เป็นวิธีการบังคับใช้การพิมพ์แบบระบุชื่อ (nominal typing) เพื่อเพิ่มความปลอดภัยของประเภท (type safety) และความชัดเจนของโค้ด บทความนี้จะสำรวจ branded types อย่างละเอียด พร้อมยกตัวอย่างที่ใช้งานได้จริงและแนวทางปฏิบัติที่ดีที่สุดสำหรับการนำไปใช้

ทำความเข้าใจ Structural vs. Nominal Typing

ก่อนที่จะเจาะลึกเรื่อง branded types เรามาทำความเข้าใจความแตกต่างระหว่าง structural และ nominal typing กันก่อน

Structural Typing (Duck Typing)

ในระบบประเภทแบบโครงสร้าง, type สองตัวจะถือว่าเข้ากันได้หากมีโครงสร้างเหมือนกัน (เช่น มี properties เหมือนกันและมี type เดียวกัน) TypeScript ใช้การพิมพ์แบบโครงสร้าง ลองพิจารณาตัวอย่างนี้:


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

interface Vector {
  x: number;
  y: number;
}

const point: Point = { x: 10, y: 20 };
const vector: Vector = point; // ใช้ได้ใน TypeScript

console.log(vector.x); // Output: 10

แม้ว่า Point และ Vector จะถูกประกาศเป็น type ที่แตกต่างกัน แต่ TypeScript อนุญาตให้กำหนดค่า object ของ Point ให้กับตัวแปร Vector ได้ เพราะทั้งสองมีโครงสร้างเหมือนกัน ซึ่งอาจจะสะดวก แต่ก็อาจนำไปสู่ข้อผิดพลาดได้หากคุณต้องการแยกแยะระหว่าง type ที่มีความหมายทางตรรกะต่างกันแต่มีรูปร่างเหมือนกันโดยบังเอิญ ตัวอย่างเช่น การคิดถึงพิกัดละติจูด/ลองจิจูดที่อาจบังเอิญไปตรงกับพิกัดพิกเซลบนหน้าจอ

Nominal Typing

ในระบบประเภทแบบระบุชื่อ, type จะถือว่าเข้ากันได้ก็ต่อเมื่อมีชื่อเดียวกันเท่านั้น แม้ว่า type สองตัวจะมีโครงสร้างเหมือนกัน แต่จะถูกมองว่าเป็นคนละ type หากมีชื่อต่างกัน ภาษาอย่าง Java และ C# ใช้การพิมพ์แบบระบุชื่อ

ความจำเป็นของ Branded Types

การพิมพ์แบบโครงสร้างของ TypeScript อาจเป็นปัญหาเมื่อคุณต้องการให้แน่ใจว่าค่าใดค่าหนึ่งเป็นของ type ที่เฉพาะเจาะจง โดยไม่คำนึงถึงโครงสร้างของมัน ตัวอย่างเช่น ลองพิจารณาการแสดงสกุลเงิน คุณอาจมี type ที่แตกต่างกันสำหรับ USD และ EUR แต่ทั้งสองอาจแสดงเป็นตัวเลข หากไม่มีกลไกในการแยกแยะ คุณอาจเผลอคำนวณผิดสกุลเงินได้

Branded types แก้ปัญหานี้โดยการให้คุณสร้าง type ที่แตกต่างกันซึ่งมีโครงสร้างคล้ายกัน แต่ระบบ type จะมองว่าเป็นคนละชนิดกัน ซึ่งจะช่วยเพิ่มความปลอดภัยของประเภทและป้องกันข้อผิดพลาดที่อาจเล็ดลอดไปได้

การนำ Branded Types ไปใช้ใน TypeScript

Branded types ถูกนำไปใช้โดยใช้ intersection types และ unique symbol หรือ string literal แนวคิดคือการเพิ่ม "แบรนด์" ให้กับ type เพื่อแยกความแตกต่างจาก type อื่นที่มีโครงสร้างเหมือนกัน

การใช้ Symbols (แนะนำ)

โดยทั่วไปแล้ว การใช้ symbols ในการสร้างแบรนด์เป็นวิธีที่นิยมมากกว่า เพราะ symbols รับประกันได้ว่าจะมีค่าที่ไม่ซ้ำกัน


const USD = Symbol('USD');
type USD = number & { readonly [USD]: unique symbol };

const EUR = Symbol('EUR');
type EUR = number & { readonly [EUR]: unique symbol };

function createUSD(value: number): USD {
  return value as USD;
}

function createEUR(value: number): EUR {
  return value as EUR;
}

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);

const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);

// การยกเลิกคอมเมนต์บรรทัดถัดไปจะทำให้เกิด type error
// const invalidOperation = addUSD(usd1, eur1);

ในตัวอย่างนี้ USD และ EUR คือ branded types ที่มีพื้นฐานมาจาก type number การใช้ unique symbol ทำให้แน่ใจว่า type เหล่านี้แตกต่างกัน ฟังก์ชัน createUSD และ createEUR ใช้สำหรับสร้างค่าของ type เหล่านี้ และฟังก์ชัน addUSD จะรับเฉพาะค่า USD เท่านั้น การพยายามบวกค่า EUR เข้ากับค่า USD จะส่งผลให้เกิด type error

การใช้ String Literals

คุณยังสามารถใช้ string literals ในการสร้างแบรนด์ได้ แม้ว่าวิธีนี้จะมีความทนทานน้อยกว่าการใช้ symbols เพราะ string literals ไม่ได้รับประกันว่าจะไม่ซ้ำกัน


type USD = number & { readonly __brand: 'USD' };
type EUR = number & { readonly __brand: 'EUR' };

function createUSD(value: number): USD {
  return value as USD;
}

function createEUR(value: number): EUR {
  return value as EUR;
}

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);

const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);

// การยกเลิกคอมเมนต์บรรทัดถัดไปจะทำให้เกิด type error
// const invalidOperation = addUSD(usd1, eur1);

ตัวอย่างนี้ให้ผลลัพธ์เช่นเดียวกับตัวอย่างก่อนหน้า แต่ใช้ string literals แทน symbols แม้จะง่ายกว่า แต่สิ่งสำคัญคือต้องแน่ใจว่า string literals ที่ใช้ในการสร้างแบรนด์นั้นไม่ซ้ำกันภายในโค้ดเบสของคุณ

ตัวอย่างการใช้งานจริงและกรณีศึกษา

Branded types สามารถนำไปใช้กับสถานการณ์ต่างๆ ที่คุณต้องการบังคับใช้ความปลอดภัยของประเภทที่นอกเหนือไปจากความเข้ากันได้ทางโครงสร้าง

IDs

ลองนึกถึงระบบที่มี ID หลายประเภท เช่น UserID, ProductID และ OrderID ID ทั้งหมดนี้อาจแสดงเป็นตัวเลขหรือสตริง แต่คุณต้องการป้องกันการผสม ID ต่างประเภทกันโดยไม่ได้ตั้งใจ


const UserIDBrand = Symbol('UserID');
type UserID = string & { readonly [UserIDBrand]: unique symbol };

const ProductIDBrand = Symbol('ProductID');
type ProductID = string & { readonly [ProductIDBrand]: unique symbol };

function getUser(id: UserID): { name: string } {
  // ... ดึงข้อมูลผู้ใช้
  return { name: "Alice" };
}

function getProduct(id: ProductID): { name: string, price: number } {
  // ... ดึงข้อมูลสินค้า
  return { name: "Example Product", price: 25 };
}

function createUserID(id: string): UserID {
  return id as UserID;
}

function createProductID(id: string): ProductID {
  return id as ProductID;
}

const userID = createUserID('user123');
const productID = createProductID('product456');

const user = getUser(userID);
const product = getProduct(productID);

console.log("User:", user);
console.log("Product:", product);

// การยกเลิกคอมเมนต์บรรทัดถัดไปจะทำให้เกิด type error
// const invalidCall = getUser(productID);

ตัวอย่างนี้แสดงให้เห็นว่า branded types สามารถป้องกันการส่ง ProductID ไปยังฟังก์ชันที่คาดหวัง UserID ซึ่งช่วยเพิ่มความปลอดภัยของประเภท

ค่าเฉพาะทางโดเมน (Domain-Specific Values)

Branded types ยังมีประโยชน์ในการแสดงค่าเฉพาะทางโดเมนที่มีข้อจำกัด ตัวอย่างเช่น คุณอาจมี type สำหรับเปอร์เซ็นต์ซึ่งควรมีค่าอยู่ระหว่าง 0 ถึง 100 เสมอ


const PercentageBrand = Symbol('Percentage');
type Percentage = number & { readonly [PercentageBrand]: unique symbol };

function createPercentage(value: number): Percentage {
  if (value < 0 || value > 100) {
    throw new Error('Percentage must be between 0 and 100');
  }
  return value as Percentage;
}

function applyDiscount(price: number, discount: Percentage): number {
  return price * (1 - discount / 100);
}

try {
  const discount = createPercentage(20);
  const discountedPrice = applyDiscount(100, discount);
  console.log("Discounted Price:", discountedPrice);

  // การยกเลิกคอมเมนต์บรรทัดถัดไปจะทำให้เกิด error ขณะรันไทม์
  // const invalidPercentage = createPercentage(120);
} catch (error) {
  console.error(error);
}

ตัวอย่างนี้แสดงวิธีการบังคับใช้ข้อจำกัดกับค่าของ branded type ในขณะรันไทม์ แม้ว่าระบบ type จะไม่สามารถรับประกันได้ว่าค่า Percentage จะอยู่ระหว่าง 0 ถึง 100 เสมอ แต่ฟังก์ชัน createPercentage สามารถบังคับใช้ข้อจำกัดนี้ในขณะรันไทม์ได้ คุณยังสามารถใช้ไลบรารีอย่าง io-ts เพื่อบังคับการตรวจสอบ branded types ขณะรันไทม์ได้อีกด้วย

การแสดงผลวันที่และเวลา

การทำงานกับวันที่และเวลานั้นอาจยุ่งยากเนื่องจากมีรูปแบบและเขตเวลาที่หลากหลาย Branded types สามารถช่วยแยกความแตกต่างระหว่างการแสดงผลวันที่และเวลาที่แตกต่างกันได้


const UTCDateBrand = Symbol('UTCDate');
type UTCDate = string & { readonly [UTCDateBrand]: unique symbol };

const LocalDateBrand = Symbol('LocalDate');
type LocalDate = string & { readonly [LocalDateBrand]: unique symbol };

function createUTCDate(dateString: string): UTCDate {
  // ตรวจสอบว่า date string อยู่ในรูปแบบ UTC (เช่น ISO 8601 ที่มี Z)
  if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(dateString)) {
    throw new Error('Invalid UTC date format');
  }
  return dateString as UTCDate;
}

function createLocalDate(dateString: string): LocalDate {
  // ตรวจสอบว่า date string อยู่ในรูปแบบ local date (เช่น YYYY-MM-DD)
  if (!/\d{4}-\d{2}-\d{2}/.test(dateString)) {
    throw new Error('Invalid local date format');
  }
  return dateString as LocalDate;
}

function convertUTCDateToLocalDate(utcDate: UTCDate): LocalDate {
  // ทำการแปลงเขตเวลา
  const date = new Date(utcDate);
  const localDateString = date.toLocaleDateString();
  return createLocalDate(localDateString);
}

try {
  const utcDate = createUTCDate('2024-01-20T10:00:00.000Z');
  const localDate = convertUTCDateToLocalDate(utcDate);
  console.log("UTC Date:", utcDate);
  console.log("Local Date:", localDate);
} catch (error) {
  console.error(error);
}

ตัวอย่างนี้แยกความแตกต่างระหว่างวันที่ UTC และวันที่ท้องถิ่น เพื่อให้แน่ใจว่าคุณกำลังทำงานกับการแสดงผลวันที่และเวลาที่ถูกต้องในส่วนต่างๆ ของแอปพลิเคชันของคุณ การตรวจสอบขณะรันไทม์ช่วยให้แน่ใจว่าเฉพาะสตริงวันที่ที่จัดรูปแบบอย่างถูกต้องเท่านั้นที่สามารถกำหนดให้กับ type เหล่านี้ได้

แนวทางปฏิบัติที่ดีที่สุดสำหรับการใช้ Branded Types

เพื่อให้การใช้ branded types ใน TypeScript มีประสิทธิภาพ ควรพิจารณาแนวทางปฏิบัติต่อไปนี้:

ข้อดีของ Branded Types

ข้อเสียของ Branded Types

ทางเลือกอื่นนอกเหนือจาก Branded Types

แม้ว่า branded types จะเป็นเทคนิคที่ทรงพลังในการทำให้เกิด nominal typing ใน TypeScript แต่ก็มีแนวทางอื่นที่คุณอาจพิจารณา

Opaque Types

Opaque types คล้ายกับ branded types แต่ให้วิธีการซ่อน type พื้นฐานที่ชัดเจนกว่า TypeScript ไม่มีการสนับสนุน opaque types ในตัว แต่คุณสามารถจำลองได้โดยใช้โมดูลและ private symbols

Classes

การใช้ classes สามารถให้แนวทางเชิงวัตถุ (object-oriented) มากขึ้นในการกำหนด type ที่แตกต่างกัน แม้ว่า classes ใน TypeScript จะเป็นแบบ structural typing แต่ก็มีการแยกส่วนความรับผิดชอบที่ชัดเจนกว่าและสามารถใช้เพื่อบังคับใช้ข้อจำกัดผ่านเมธอดได้

ไลบรารีอย่าง `io-ts` หรือ `zod`

ไลบรารีเหล่านี้ให้การตรวจสอบ type ขณะรันไทม์ที่ซับซ้อน และสามารถใช้ร่วมกับ branded types เพื่อให้แน่ใจว่ามีความปลอดภัยทั้งในขณะคอมไพล์และขณะรันไทม์

สรุป

Branded types ของ TypeScript เป็นเครื่องมือที่มีค่าในการเพิ่มความปลอดภัยของประเภทและความชัดเจนของโค้ดในระบบประเภทแบบโครงสร้าง โดยการเพิ่ม "แบรนด์" ให้กับ type คุณสามารถบังคับใช้ nominal typing และป้องกันการผสม type ที่มีโครงสร้างคล้ายกันแต่มีความหมายทางตรรกะต่างกันโดยไม่ได้ตั้งใจ แม้ว่า branded types จะเพิ่มความซับซ้อนและค่าใช้จ่ายเล็กน้อย แต่ประโยชน์ในด้านความปลอดภัยของประเภทและความสามารถในการบำรุงรักษาโค้ดที่เพิ่มขึ้นมักจะคุ้มค่ากว่าข้อเสีย ลองพิจารณาใช้ branded types ในสถานการณ์ที่คุณต้องการให้แน่ใจว่าค่าใดค่าหนึ่งเป็นของ type ที่เฉพาะเจาะจง โดยไม่คำนึงถึงโครงสร้างของมัน

โดยการทำความเข้าใจหลักการเบื้องหลังของ structural และ nominal typing และโดยการนำแนวทางปฏิบัติที่ดีที่สุดที่ระบุไว้ในบทความนี้ไปใช้ คุณจะสามารถใช้ประโยชน์จาก branded types ได้อย่างมีประสิทธิภาพเพื่อเขียนโค้ด TypeScript ที่แข็งแกร่งและบำรุงรักษาง่ายขึ้น ตั้งแต่การแสดงสกุลเงินและ ID ไปจนถึงการบังคับใช้ข้อจำกัดเฉพาะทางโดเมน Branded types เป็นกลไกที่ยืดหยุ่นและทรงพลังในการเพิ่มความปลอดภัยของประเภทในโปรเจกต์ของคุณ

ในขณะที่คุณทำงานกับ TypeScript ลองสำรวจเทคนิคและไลบรารีต่างๆ ที่มีอยู่สำหรับการตรวจสอบและบังคับใช้ type ลองพิจารณาใช้ branded types ร่วมกับไลบรารีการตรวจสอบขณะรันไทม์อย่าง io-ts หรือ zod เพื่อให้ได้แนวทางที่ครอบคลุมในด้านความปลอดภัยของประเภท