สำรวจ Exact Types ของ TypeScript สำหรับการจับคู่รูปร่างอ็อบเจกต์ที่เข้มงวด ป้องกัน properties ที่ไม่คาดคิด และรับประกันความแข็งแกร่งของโค้ด เรียนรู้การใช้งานจริงและแนวทางปฏิบัติที่ดีที่สุด
TypeScript Exact Types: การจับคู่รูปร่างอ็อบเจกต์ที่เข้มงวดเพื่อโค้ดที่แข็งแกร่ง
TypeScript ซึ่งเป็นส่วนขยายของ JavaScript ได้นำ static typing มาสู่โลกของการพัฒนาเว็บแบบไดนามิก แม้ว่า TypeScript จะมีข้อดีอย่างมากในด้านความปลอดภัยของไทป์ (type safety) และการบำรุงรักษาโค้ด แต่ระบบ structural typing ของมันบางครั้งอาจนำไปสู่พฤติกรรมที่ไม่คาดคิดได้ นี่คือจุดที่แนวคิดของ "exact types" เข้ามามีบทบาท แม้ว่า TypeScript จะไม่มีฟีเจอร์ในตัวที่ชื่อว่า "exact types" อย่างชัดเจน แต่เราสามารถบรรลุพฤติกรรมที่คล้ายกันได้ผ่านการผสมผสานฟีเจอร์และเทคนิคต่างๆ ของ TypeScript บล็อกโพสต์นี้จะเจาะลึกถึงวิธีการบังคับใช้การจับคู่รูปร่างอ็อบเจกต์ที่เข้มงวดมากขึ้นใน TypeScript เพื่อปรับปรุงความแข็งแกร่งของโค้ดและป้องกันข้อผิดพลาดทั่วไป
ทำความเข้าใจ Structural Typing ของ TypeScript
TypeScript ใช้ระบบ structural typing (หรือที่รู้จักในชื่อ duck typing) ซึ่งหมายความว่าความเข้ากันได้ของไทป์จะถูกกำหนดโดยสมาชิกของไทป์นั้นๆ ไม่ใช่จากชื่อที่ประกาศไว้ หากอ็อบเจกต์มี properties ทั้งหมดที่ไทป์ต้องการ ก็จะถือว่าเข้ากันได้กับไทป์นั้น โดยไม่คำนึงว่าจะมี properties เพิ่มเติมหรือไม่
ตัวอย่างเช่น:
interface Point {
x: number;
y: number;
}
const myPoint = { x: 10, y: 20, z: 30 };
function printPoint(point: Point) {
console.log(`X: ${point.x}, Y: ${point.y}`);
}
printPoint(myPoint); // ทำงานได้ปกติ แม้ว่า myPoint จะมี property 'z'
ในสถานการณ์นี้ TypeScript อนุญาตให้ส่ง `myPoint` ไปยัง `printPoint` ได้ เนื่องจากมันมี properties ที่จำเป็นคือ `x` และ `y` ถึงแม้ว่าจะมี property `z` เพิ่มเติมก็ตาม แม้ความยืดหยุ่นนี้จะสะดวก แต่ก็อาจนำไปสู่บักเล็กๆ น้อยๆ ได้ หากคุณส่งอ็อบเจกต์ที่มี properties ที่ไม่คาดคิดเข้าไปโดยไม่ได้ตั้งใจ
ปัญหากับ Properties ส่วนเกิน
ความยืดหยุ่นของ structural typing บางครั้งอาจบดบังข้อผิดพลาดได้ ลองพิจารณาฟังก์ชันที่ต้องการอ็อบเจกต์การตั้งค่า (configuration object):
interface Config {
apiUrl: string;
timeout: number;
}
function setup(config: Config) {
console.log(`API URL: ${config.apiUrl}`);
console.log(`Timeout: ${config.timeout}`);
}
const myConfig = { apiUrl: "https://api.example.com", timeout: 5000, typo: true };
setup(myConfig); // TypeScript ไม่แจ้งเตือนข้อผิดพลาดตรงนี้!
console.log(myConfig.typo); //prints true. property ส่วนเกินยังคงอยู่เงียบๆ
ในตัวอย่างนี้ `myConfig` มี property ส่วนเกินคือ `typo` แต่ TypeScript ไม่แจ้งข้อผิดพลาดเนื่องจาก `myConfig` ยังคงสอดคล้องกับ interface `Config` อย่างไรก็ตาม การพิมพ์ผิด (typo) นี้ไม่เคยถูกตรวจจับ และแอปพลิเคชันอาจทำงานไม่เป็นไปตามที่คาดหวังหากการพิมพ์ผิดนั้นตั้งใจจะเป็น `typoo` ปัญหาที่ดูเหมือนเล็กน้อยเหล่านี้สามารถเติบโตเป็นเรื่องปวดหัวครั้งใหญ่ได้เมื่อต้องดีบักแอปพลิเคชันที่ซับซ้อน property ที่หายไปหรือสะกดผิดอาจตรวจจับได้ยากเป็นพิเศษเมื่อต้องจัดการกับอ็อบเจกต์ที่ซ้อนกันอยู่ภายในอ็อบเจกต์อื่น
แนวทางในการบังคับใช้ Exact Types ใน TypeScript
แม้ว่า "exact types" ที่แท้จริงจะไม่มีให้ใช้โดยตรงใน TypeScript แต่ก็มีเทคนิคหลายอย่างเพื่อให้ได้ผลลัพธ์ที่คล้ายกันและบังคับใช้การจับคู่รูปร่างอ็อบเจกต์ที่เข้มงวดมากขึ้น:
1. การใช้ Type Assertions ร่วมกับ Omit
Utility type อย่าง `Omit` ช่วยให้คุณสามารถสร้างไทป์ใหม่โดยการยกเว้น properties บางอย่างออกจากไทป์ที่มีอยู่ เมื่อใช้ร่วมกับ type assertion จะสามารถช่วยป้องกัน properties ส่วนเกินได้
interface Point {
x: number;
y: number;
}
const myPoint = { x: 10, y: 20, z: 30 };
// สร้างไทป์ที่มีเฉพาะ properties ของ Point
const exactPoint: Point = myPoint as Omit & Point;
// Error: Type '{ x: number; y: number; z: number; }' is not assignable to type 'Point'.
// Object literal may only specify known properties, and 'z' does not exist in type 'Point'.
function printPoint(point: Point) {
console.log(`X: ${point.x}, Y: ${point.y}`);
}
//Fix
const myPointCorrect = { x: 10, y: 20 };
const exactPointCorrect: Point = myPointCorrect as Omit & Point;
printPoint(exactPointCorrect);
แนวทางนี้จะโยนข้อผิดพลาดหาก `myPoint` มี properties ที่ไม่ได้กำหนดไว้ใน interface `Point`
คำอธิบาย: `Omit
2. การใช้ฟังก์ชันเพื่อสร้างอ็อบเจกต์
คุณสามารถสร้าง factory function ที่ยอมรับเฉพาะ properties ที่กำหนดไว้ใน interface เท่านั้น วิธีนี้ให้การตรวจสอบไทป์ที่แข็งแกร่ง ณ จุดที่สร้างอ็อบเจกต์
interface Config {
apiUrl: string;
timeout: number;
}
function createConfig(config: Config): Config {
return {
apiUrl: config.apiUrl,
timeout: config.timeout,
};
}
const myConfig = createConfig({ apiUrl: "https://api.example.com", timeout: 5000 });
//This will not compile:
//const myConfigError = createConfig({ apiUrl: "https://api.example.com", timeout: 5000, typo: true });
//Argument of type '{ apiUrl: string; timeout: number; typo: true; }' is not assignable to parameter of type 'Config'.
// Object literal may only specify known properties, and 'typo' does not exist in type 'Config'.
ด้วยการคืนค่าอ็อบเจกต์ที่สร้างขึ้นจาก properties ที่กำหนดไว้ใน interface `Config` เท่านั้น คุณจะมั่นใจได้ว่าจะไม่มี property ส่วนเกินเล็ดลอดเข้ามาได้ ทำให้การสร้าง config ปลอดภัยยิ่งขึ้น
3. การใช้ Type Guards
Type guard คือฟังก์ชันที่ช่วยจำกัดขอบเขตของไทป์ของตัวแปรภายใน scope ที่กำหนด แม้ว่าจะไม่ได้ป้องกัน properties ส่วนเกินโดยตรง แต่ก็สามารถช่วยให้คุณตรวจสอบได้อย่างชัดเจนและดำเนินการที่เหมาะสมได้
interface User {
id: number;
name: string;
}
function isUser(obj: any): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj && typeof obj.id === 'number' &&
'name' in obj && typeof obj.name === 'string' &&
Object.keys(obj).length === 2 //check for number of keys. Note: brittle and depends on User's exact key count.
);
}
const potentialUser1 = { id: 123, name: "Alice" };
const potentialUser2 = { id: 456, name: "Bob", extra: true };
if (isUser(potentialUser1)) {
console.log("Valid User:", potentialUser1.name);
} else {
console.log("Invalid User");
}
if (isUser(potentialUser2)) {
console.log("Valid User:", potentialUser2.name); //Will not hit here
} else {
console.log("Invalid User");
}
ในตัวอย่างนี้ type guard `isUser` ไม่เพียงแต่ตรวจสอบการมีอยู่ของ properties ที่จำเป็นเท่านั้น แต่ยังตรวจสอบไทป์ของมันและจำนวน property ที่ *ตรงเผง* อีกด้วย แนวทางนี้มีความชัดเจนมากขึ้นและช่วยให้คุณสามารถจัดการกับอ็อบเจกต์ที่ไม่ถูกต้องได้อย่างเหมาะสม อย่างไรก็ตาม การตรวจสอบจำนวน properties นั้นเปราะบาง เมื่อใดก็ตามที่ `User` มีการเพิ่ม/ลด properties การตรวจสอบนี้จะต้องได้รับการอัปเดตตามไปด้วย
4. การใช้ประโยชน์จาก Readonly และ as const
ในขณะที่ `Readonly` ป้องกันการแก้ไข properties ที่มีอยู่ และ `as const` สร้าง tuple หรืออ็อบเจกต์แบบอ่านอย่างเดียวที่ properties ทั้งหมดเป็นแบบ read-only และมี literal types แต่ก็สามารถนำมาใช้เพื่อสร้างคำจำกัดความและการตรวจสอบไทป์ที่เข้มงวดขึ้นเมื่อใช้ร่วมกับวิธีอื่น ๆ อย่างไรก็ตาม ไม่มีวิธีใดที่ป้องกัน properties ส่วนเกินได้ด้วยตัวเอง
interface Options {
width: number;
height: number;
}
//Create the Readonly type
type ReadonlyOptions = Readonly;
const options: ReadonlyOptions = { width: 100, height: 200 };
//options.width = 300; //error: Cannot assign to 'width' because it is a read-only property.
//Using as const
const config = { api_url: "https://example.com", timeout: 3000 } as const;
//config.timeout = 5000; //error: Cannot assign to 'timeout' because it is a read-only property.
//However, excess properties are still allowed:
const invalidOptions: ReadonlyOptions = { width: 100, height: 200, depth: 300 }; //no error. Still allows excess properties.
interface StrictOptions {
readonly width: number;
readonly height: number;
}
//This will now error:
//const invalidStrictOptions: StrictOptions = { width: 100, height: 200, depth: 300 };
//Type '{ width: number; height: number; depth: number; }' is not assignable to type 'StrictOptions'.
// Object literal may only specify known properties, and 'depth' does not exist in type 'StrictOptions'.
วิธีนี้ช่วยปรับปรุง immutability (การไม่เปลี่ยนรูป) แต่ป้องกันได้แค่การแก้ไข (mutation) ไม่ใช่การมีอยู่ของ property ส่วนเกิน เมื่อใช้ร่วมกับ `Omit` หรือแนวทางการใช้ฟังก์ชัน ก็จะทำให้มีประสิทธิภาพมากขึ้น
5. การใช้ไลบรารี (เช่น Zod, io-ts)
ไลบรารีอย่าง Zod และ io-ts มีความสามารถในการตรวจสอบไทป์ขณะรันไทม์ (runtime type validation) และการกำหนด schema ที่ทรงพลัง ไลบรารีเหล่านี้ช่วยให้คุณสามารถกำหนด schema ที่อธิบายรูปร่างที่คาดหวังของข้อมูลได้อย่างแม่นยำ รวมถึงการป้องกัน properties ส่วนเกินด้วย แม้ว่าจะเพิ่ม dependency ขณะรันไทม์ แต่ก็เป็นโซลูชันที่แข็งแกร่งและยืดหยุ่นมาก
ตัวอย่างด้วย Zod:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
type User = z.infer;
const validUser = { id: 1, name: "John" };
const invalidUser = { id: 2, name: "Jane", extra: true };
const parsedValidUser = UserSchema.parse(validUser);
console.log("Parsed Valid User:", parsedValidUser);
try {
const parsedInvalidUser = UserSchema.parse(invalidUser);
console.log("Parsed Invalid User:", parsedInvalidUser); // This won't be reached
} catch (error) {
console.error("Validation Error:", error.errors);
}
เมธอด `parse` ของ Zod จะโยนข้อผิดพลาดหากข้อมูลอินพุตไม่สอดคล้องกับ schema ซึ่งช่วยป้องกัน properties ส่วนเกินได้อย่างมีประสิทธิภาพ สิ่งนี้ให้การตรวจสอบขณะรันไทม์และยังสร้างไทป์ TypeScript จาก schema เพื่อให้แน่ใจว่าคำจำกัดความของไทป์ของคุณสอดคล้องกับตรรกะการตรวจสอบขณะรันไทม์
แนวทางปฏิบัติที่ดีที่สุดสำหรับการบังคับใช้ Exact Types
นี่คือแนวทางปฏิบัติที่ดีที่สุดบางประการที่ควรพิจารณาเมื่อบังคับใช้การจับคู่รูปร่างอ็อบเจกต์ที่เข้มงวดมากขึ้นใน TypeScript:
- เลือกเทคนิคที่เหมาะสม: แนวทางที่ดีที่สุดขึ้นอยู่กับความต้องการเฉพาะและข้อกำหนดของโปรเจกต์ของคุณ สำหรับกรณีง่ายๆ การใช้ type assertion กับ `Omit` หรือ factory function อาจเพียงพอ สำหรับสถานการณ์ที่ซับซ้อนมากขึ้นหรือเมื่อต้องการการตรวจสอบขณะรันไทม์ ควรพิจารณาใช้ไลบรารีอย่าง Zod หรือ io-ts
- มีความสม่ำเสมอ: ใช้แนวทางที่คุณเลือกอย่างสม่ำเสมอทั่วทั้ง codebase ของคุณเพื่อรักษาระดับความปลอดภัยของไทป์ที่สม่ำเสมอ
- จัดทำเอกสารไทป์ของคุณ: จัดทำเอกสาร interface และไทป์ของคุณอย่างชัดเจนเพื่อสื่อสารรูปร่างที่คาดหวังของข้อมูลของคุณไปยังนักพัฒนาคนอื่น
- ทดสอบโค้ดของคุณ: เขียน unit test เพื่อตรวจสอบว่าข้อจำกัดของไทป์ของคุณทำงานตามที่คาดหวังและโค้ดของคุณจัดการกับข้อมูลที่ไม่ถูกต้องได้อย่างเหมาะสม
- พิจารณาข้อดีข้อเสีย: การบังคับใช้การจับคู่รูปร่างอ็อบเจกต์ที่เข้มงวดขึ้นสามารถทำให้โค้ดของคุณแข็งแกร่งขึ้น แต่ก็อาจเพิ่มเวลาในการพัฒนาได้เช่นกัน ชั่งน้ำหนักระหว่างประโยชน์กับต้นทุนและเลือกแนวทางที่เหมาะสมที่สุดสำหรับโปรเจกต์ของคุณ
- การปรับใช้ทีละน้อย: หากคุณกำลังทำงานกับ codebase ขนาดใหญ่ที่มีอยู่แล้ว ให้พิจารณาปรับใช้เทคนิคเหล่านี้ทีละน้อย โดยเริ่มจากส่วนที่สำคัญที่สุดของแอปพลิเคชันของคุณ
- ควรใช้ interface มากกว่า type alias เมื่อกำหนดรูปร่างอ็อบเจกต์: โดยทั่วไปแล้ว interface เป็นที่นิยมมากกว่าเพราะรองรับการรวมการประกาศ (declaration merging) ซึ่งมีประโยชน์สำหรับการขยายไทป์ข้ามไฟล์ต่างๆ
ตัวอย่างการใช้งานจริง
เรามาดูสถานการณ์การใช้งานจริงบางส่วนที่ exact types สามารถเป็นประโยชน์ได้:
- Payload ของคำขอ API: เมื่อส่งข้อมูลไปยัง API เป็นสิ่งสำคัญอย่างยิ่งที่จะต้องแน่ใจว่า payload สอดคล้องกับ schema ที่คาดหวัง การบังคับใช้ exact types สามารถป้องกันข้อผิดพลาดที่เกิดจากการส่ง properties ที่ไม่คาดคิดได้ ตัวอย่างเช่น API การประมวลผลการชำระเงินจำนวนมากมีความละเอียดอ่อนอย่างยิ่งต่อข้อมูลที่ไม่คาดคิด
- ไฟล์การตั้งค่า (Configuration files): ไฟล์การตั้งค่ามักมี properties จำนวนมาก และการพิมพ์ผิดอาจเกิดขึ้นได้บ่อยครั้ง การใช้ exact types สามารถช่วยจับการพิมพ์ผิดเหล่านี้ได้ตั้งแต่เนิ่นๆ หากคุณกำลังตั้งค่าตำแหน่งเซิร์ฟเวอร์ในการปรับใช้บนคลาวด์ การพิมพ์ผิดในการตั้งค่าตำแหน่ง (เช่น eu-west-1 เทียบกับ eu-wet-1) จะกลายเป็นการดีบักที่ยากอย่างยิ่งหากไม่ถูกตรวจจับตั้งแต่แรก
- ไปป์ไลน์การแปลงข้อมูล (Data transformation pipelines): เมื่อแปลงข้อมูลจากรูปแบบหนึ่งไปยังอีกรูปแบบหนึ่ง สิ่งสำคัญคือต้องแน่ใจว่าข้อมูลผลลัพธ์สอดคล้องกับ schema ที่คาดหวัง
- คิวข้อความ (Message queues): เมื่อส่งข้อความผ่านคิวข้อความ สิ่งสำคัญคือต้องแน่ใจว่า payload ของข้อความนั้นถูกต้องและมี properties ที่ถูกต้อง
ตัวอย่าง: การตั้งค่า Internationalization (i18n)
ลองจินตนาการถึงการจัดการคำแปลสำหรับแอปพลิเคชันหลายภาษา คุณอาจมีอ็อบเจกต์การตั้งค่าดังนี้:
interface Translation {
greeting: string;
farewell: string;
}
interface I18nConfig {
locale: string;
translations: Translation;
}
const englishConfig: I18nConfig = {
locale: "en-US",
translations: {
greeting: "Hello",
farewell: "Goodbye"
}
};
//This will be an issue, as an excess property exists, silently introducing a bug.
const spanishConfig: I18nConfig = {
locale: "es-ES",
translations: {
greeting: "Hola",
farewell: "Adiós",
typo: "unintentional translation"
}
};
//Solution: Using Omit
const spanishConfigCorrect: I18nConfig = {
locale: "es-ES",
translations: {
greeting: "Hola",
farewell: "Adiós"
} as Omit & Translation
};
หากไม่มี exact types การพิมพ์ผิดในคีย์คำแปล (เช่น การเพิ่มฟิลด์ `typo`) อาจไม่มีใครสังเกตเห็น ซึ่งนำไปสู่การแปลที่ขาดหายไปในส่วนติดต่อผู้ใช้ ด้วยการบังคับใช้การจับคู่รูปร่างอ็อบเจกต์ที่เข้มงวดขึ้น คุณสามารถจับข้อผิดพลาดเหล่านี้ได้ในระหว่างการพัฒนาและป้องกันไม่ให้ไปถึงเวอร์ชันโปรดักชัน
สรุป
แม้ว่า TypeScript จะไม่มี "exact types" ในตัว แต่คุณสามารถบรรลุผลลัพธ์ที่คล้ายกันได้โดยใช้การผสมผสานระหว่างฟีเจอร์และเทคนิคของ TypeScript เช่น type assertion กับ `Omit`, factory functions, type guards, `Readonly`, `as const` และไลบรารีภายนอกอย่าง Zod และ io-ts ด้วยการบังคับใช้การจับคู่รูปร่างอ็อบเจกต์ที่เข้มงวดขึ้น คุณสามารถปรับปรุงความแข็งแกร่งของโค้ด ป้องกันข้อผิดพลาดทั่วไป และทำให้แอปพลิเคชันของคุณมีความน่าเชื่อถือมากขึ้น อย่าลืมเลือกแนวทางที่เหมาะสมกับความต้องการของคุณมากที่สุดและนำไปใช้อย่างสม่ำเสมอทั่วทั้ง codebase ของคุณ ด้วยการพิจารณาแนวทางเหล่านี้อย่างรอบคอบ คุณจะสามารถควบคุมไทป์ของแอปพลิเคชันได้ดียิ่งขึ้นและเพิ่มความสามารถในการบำรุงรักษาในระยะยาว