Runtime Type Checking ด้วยภาษา TypeScript
บทความนี้เป็นเนื้อหาต่อเนื่องจาก บทความ Type Safe Errors ด้วยภาษา TypeScript
ภาษา TypeScript ช่วยให้เราสามารถนิยามรูปร่างข้อมูลของตัวแปรต่าง ๆ ได้ ตัวแปรใด ๆ ที่ทราบชนิดข้อมูลที่แน่ชัดในช่วงคอมไพล์ หากมีการประกาศผิดรูปร่าง TypeScript ย่อมมีความสามารถในการแจ้งเตือนข้อผิดพลาดนั้นให้ทราบได้
1interface Person {2 name: string3 age: number4}56const somchai: Person = {7 // Type 'number' is not assignable to type 'string'8 name: 24,9 // Type 'string' is not assignable to type 'number'10 age: 'Somchai',11}
ตัวอย่างข้างต้นตัวแปร somchai ถูกประกาศขึ้นมาให้มีชนิดข้อมูลเป็น Person พร้อมกันนี้ได้ทำการระบุค่าของแต่ละพร็อพเพอร์ตี้ในออบเจ็กต์แล้ว ในช่วงคอมไพล์ TypeScript จึงสามารถตรวจสอบค่าของแต่ละฟิลด์กับชนิดข้อมูล Person พร้อมแจ้งเตือนข้อผิดพลาดให้ทราบได้
จะเกิดอะไรขึ้นถ้าค่าข้อมูลของ somchai ไม่เป็นที่ทราบแน่ชัดในช่วงคอมไพล์ หากแต่เป็นข้อมูลที่ได้รับมาจากผู้ใช้งาน หรืออาจเป็นการส่งข้อมูลมาจาก API
ข้อผิดพลาดของชนิดข้อมูลช่วง Runtime
กำหนดให้เรามีฟังก์ชันชื่อ request ที่เมื่อเรียกใช้แล้วจะทำการร้องขอข้อมูลจาก URL ตามที่ระบุ
1async function request<Data>(url: string) {2 // ดึงข้อมูลจาก API ตาม URL ที่ระบุ3 const res = await fetch(url)4 // ดึงข้อมูลเป็น JSON จาก Response5 const data: Data = await res.json()67 return data89 // โค้ดชุดนี้ตัดการจัดการ error ออกไป10}
สมมติว่าการร้องขอข้อมูลไปที่ myuser.com/somchai จะได้ข้อมูลของ somchai กลับมาเป็นชนิดข้อมูล Person การเรียกใช้งาน request ของเราจึงควรเป็นดังนี้
1const url = 'http://myuser.com/somchai'2const somchai = await request<Person>(url)34// TypeScript รู้ชนิดข้อมูลของ somchai ว่าเป็น Person5// เพราะฟังก์ชัน request คืน data กลับมาด้วยค่า generic ที่ส่งเข้ามา6// (คือชนิดข้อมูล Person)7somchai.name
จากตัวอย่างที่ยกมา TypeScript ไม่ได้ทราบจริง ๆ หรอกว่า URL ดังกล่าวจะคืนข้อมูลด้วยชนิดข้อมูล Person หรือไม่ หากแต่เราใช้ Generic Parameter (ชื่อ Data) ในการป้อนให้ฟังก์ชัน request คืนชนิดข้อมูลเป็น Generic Parameter ที่รับเข้ามา ระบบจึงเหมือนมโนไปเองว่าตัวแปร somchai เป็นชนิดข้อมูล Person นั่นเอง
จะเกิดอะไรขึ้นถ้า URL ที่ระบุไม่คืนค่ากลับแบบที่เราต้องการ เช่น แต่เดิมควรคืนค่าเป็นชนิดข้อมูล Person แต่แท้จริงแล้วกลับส่งชนิดข้อมูลเป็น { person: Person } แทน
1// ตัวอย่าง response ที่ไม่ได้คาดหวัง แต่ API ส่งคืนมา2{3 // มีการครอบทับด้วย person แต่ที่เราคาดหวังไว้ไม่ควรมี4 "person": {5 "name": "Somchai",6 "age": 247 }8}
แน่นอนว่าถ้าผลลัพธ์จาก runtime ไม่ถูกต้องการเรียก somchai.name
จากตัวอย่างก่อนหน้าย่อมทำไม่ได้
การตรวจสอบชนิดข้อมูลช่วง runtime
เมื่อผลลัพธ์จาก API อาจไม่ตรงตามคาดหวัง ชนิดข้อมูลที่คืนกลับจากฟังก์ชัน request จึงไม่ควรเป็น Person แต่ควรเป็น unknown ที่สื่อความว่ายังไม่รู้ชนิดข้อมูล runtime ณ ขณะนั้น
1const url = 'http://myuser.com/somchai'2const somchai = await request(url)34// somchai เป็นชนิดข้อมูล unknown5// เราจึงยังเรียกใช้ name ไม่ได้6somchai.name // ทำไม่ได้
ก่อนที่ตัวแปร somchai จะถูกนำไปใช้ เราควรตรวจสอบชนิดข้อมูลที่แท้จริงของมันเสียก่อน สิ่งหนึ่งที่ในบทความนี้จะทำคือการนิยามโครงสร้างของข้อมูลที่คาดหวังขึ้นมา
1import * as t from './runtime-types'23const person = t.schema({4 name: t.string,5 age: t.number,6})
ไฟล์ ./runtime-types เป็นสิ่งที่เราจะสร้างขึ้นในครึ่งหลังของบทความ สำหรับตัวอย่างนี้เป็นการประกาศ person มีโครงสร้างที่คาดหวังคือต้องเป็นออบเจ็กต์ที่ประกอบด้วยค่า name เป็น string และ age เป็น number
ภายหลังการนิยามโครงสร้างแล้วตัวแปร somchai จะยังนำไปใช้ไม่ได้เพราะเราไม่ทราบว่า API ได้คืน somchai กลับมาแบบที่เราคาดหวังหรือไม่ เราต้องนำตัวแปร somchai มาทำการเทียบกับโครงสร้าง person ก่อนดังนี้
1const url = 'http://myuser.com/somchai'2const somchai = await request(url)34person.decode(somchai)
ตัวอย่างข้างต้น person ที่เป็นตัวแปรสำหรับนิยามโครงสร้างจะมีเมธอด decode ไว้รับค่าที่มาจาก runtime (ในตัวอย่างนี้คือ somchai ที่เป็นค่าจาก API) แล้วทำการวิเคราะห์โครงสร้างว่ามีชนิดข้อมูลถูกต้องหรือไม่
เมธอด decode จะคืนกลับเป็นชนิดข้อมูล Result ทำให้เราสามารถใช้ match เพื่อสร้างสองทางเลือกได้ หากข้อมูลมีโครงสร้างที่ถูกต้องอาร์กิวเมนต์ตัวแรกที่ส่งให้กับ match จะถูกเรียก ในทางกลับกันเมื่อโครงสร้างผิดฟังก์ชันที่สองที่ส่งให้กับ match จะถูกเรียกแทน
1person.decode(somchai).match(2 // data คือค่าของ somchai ที่มีชนิดข้อมูลเป็น Person3 (data) => console.log(data),4 // ฟังก์ชันนี้ถูกเรียกเมื่อ somchai ไม่ได้มีโครงสร้างเป็น person5 (error) => console.log(error)6)
ชนิดข้อมูล Type
ชนิดข้อมูลใหม่ที่นิยามนี้อยู่ภายใต้ไฟล์ ./runtime-types.ts เวลาเรียกใช้งานจะเป็น
TypeScript1import * as t from './runtime-types'
จากการสร้างตัวแปร person เพื่อกำหนดโครงสร้างข้อมูลนั้น t.schema, t.string และ t.number ล้วนมีชนิดข้อมูลเป็น Type ชนิดข้อมูลนี้สร้างผ่านคลาสที่มี constructor รับค่าสองค่า ค่าแรกคือฟังก์ชันสำหรับการตรวจสอบค่า runtime ที่ส่งเข้ามา (ต่อไปนี้จะเรียกว่าฟังก์ชัน is) ส่วนค่าที่สองนั้นเป็นฟังก์ชันที่จะคืนกลับเป็นข้อความ error เมื่อการ decode นั้นไม่สำเร็จ
กรณีของ t.number ที่เป็นการตรวจสอบตัวเลขนั้นเราจะสร้างมันขึ้นมาจาก Type อีกที โดยกำหนดค่าฟังก์ชัน is
ให้เป็นการตรวจสอบค่าที่ส่งมาว่ามีชนิดข้อมูลเป็นตัวเลขหรือไม่ผ่าน typeof
ส่วนฟังก์ชันที่สองจะคืนข้อมูล is not number
เมื่อเลขนั้นไม่ใช่ number
1export const number = new Type(2 (n: unknown): n is number => typeof n === 'number',3 (value) => `${value} is not number`4)
เช่นเดียวกับ t.number ส่วนของ t.string จะเป็นการตรวจสอบค่าที่รับเข้าว่ามีชนิดข้อมูลเป็น string หรือไม่นั่นเอง
1export const string = new Type(2 (s: unknown): s is string => typeof s === 'string',3 (value) => `${value} is not string`4)
จากทั้งการใช้งาน t.number และ t.string ทำให้คลาส Type มีหน้าตาเป็นดังนี้
1export class Type<T> {2 constructor(3 readonly is: Is<T>,4 readonly createErrMessage: (value: unknown) => string5 ) {}6}
เราอยากให้ชนิดข้อมูล Type มีเมธอด decode สำหรับการตรวจสอบค่าด้วย เราจะสร้างเมธอด decode ให้มีหน้าตาอย่างไรดี?
สมมติให้เราต้องการรับข้อมูลจากผู้ใช้งานเป็นอายุ (age) เราจึงออกแบบ t.number ของเราให้สามารถใช้ decode ได้ดังนี้ (ในตัวอย่างนี้ค่า input คือเลข 24)
1// นิยามโครงสร้างว่า input ต้องเป็น number2const age = t.number34age.decode(24).match(5 // n มีชนิดข้อมูลเป็น number6 (n) => console.log(n),7 (err) => console.error(err)8)
จากตัวอย่างการใช้งาน decode ของเราต้องรับพารามิเตอร์เป็นชนิดข้อมูล unknown เพราะเรายังไม่ทราบว่า ณ จังหวะที่ผู้ใช้งานระบุค่าเข้ามานั้นเขาใส่ชนิดข้อมูลใดกันแน่ ส่วนค่าที่คืนกลับจากเมธอด decode ต้องเป็น Result เพื่อให้สามารถเรียก match ต่อได้
1type Is<T> = (u: unknown) => u is T23export class Type<T> {4 // ตัวแปรนี้ใช้อ้างอิงภายหลังถึงชนิดข้อมูลที่แท้จริงของข้อมูลจาก runtime5 value!: T67 constructor(8 readonly is: Is<T>,9 readonly createErrMessage: (value: unknown) => string10 ) {}1112 decode(value: unknown): Result<T, string> {13 const isValid = this.is(value)1415 return isValid ? ok(value) : err(this.createErrMessage(value))16 }17}
การประกาศและใช้งาน schema
เป้าหมายสูงสุดคือต้องสามารถนิยามโครงสร้างของข้อมูลระดับออบเจ็กต์ได้ผ่าน schema
1import * as t from './runtime-types'23const person = t.schema({4 name: t.string,5 age: t.number,6})
นั่นรวมถึง schema ต้องสามารถ decode ข้อมูลได้อย่างถูกต้องด้วย
1person.decode(somchai).match(2 (data) => console.log(data),3 (error) => console.log(error)4)
ในกรณีที่ somchai มีค่าข้อมูลที่ไม่ถูกต้อง error ต้องแจ้งเตือนได้ว่าแต่ละพร็อพเพอร์ตี้ของ somchai มีข้อผิดพลาดอย่างไร
1// ออบเจ็กต์ที่ส่งใน decode2// ให้เสมือนหนึ่งเป็นผลลัพธ์ที่ผิดพลาดจาก API3person4 .decode({5 person: {6 name: 'Somchai',7 age: '24',8 },9 })10 .match(11 (data) => console.log(data),12 (error) => console.log(error)13 )1415// error ที่ได้รับคือ16// {17// "name": "undefined is not string",18// "age": "undefined is not number"19// }
สำหรับ schema มีวิธีการเขียนโค้ดดังนี้
1export type TypeOf<T extends Type<unknown>> = T['value']23type Schema<T extends Record<string, Type<unknown>>> = {4 [K in keyof T]: TypeOf<T[K]>5}67type TypeOfSchema<T extends Record<string, Type<unknown>>> = {8 [K in keyof T]: TypeOf<T[K]>9}1011const isObject = (u: unknown): u is Record<string, unknown> => {12 return typeof u === 'object' && u !== null13}1415const schema = <T extends Record<string, Type<unknown>>>(mapping: T) => {16 const errMessages: Record<string, unknown> = {}1718 return new Type(19 (value: unknown): value is TypeOfSchema<T> => {20 if (!isObject(value)) return false2122 let isValid = true2324 for (const [propName, validator] of Object.entries(mapping)) {25 const result = validator.decode(value[propName])2627 if (result.isErr()) {28 isValid = false29 errMessages[propName] = result.error30 }31 }3233 return isValid34 },35 () => JSON.stringify(errMessages)36 )37}
ชนิดข้อมูล TypeOf
ในหัวข้อก่อนหน้านี้เราได้สร้างชนิดข้อมูล TypeOf ไว้สำหรับคืนกลับเป็นชนิดข้อมูลที่คาดหวังจาก runtime
1type TypeOf<T extends Type<unknown>> = T['value']
เพื่อให้ได้ชนิดข้อมูลที่แท้จริงเราสามารถใช้ TypeOf ได้ดังนี้
1const person = t.schema({2 name: t.string,3 age: t.number,4})56type Person = t.TypeOf<typeof person>78// มีค่าเท่ากับ9// type Person = {10// name: string;11// age: string;12// }
เมื่อเปรียบเทียบแล้วการใช้ชนิดข้อมูล Type ดีกว่าอย่างไร
หากเราไม่ได้ใช้ Type เราจำเป็นต้องนิยามชนิดข้อมูลของ runtime ขึ้นมาก่อน จากนั้นจึงทำการตรวจสอบความถูกต้องของข้อมูลภายหลัง เช่น
1interface Person {2 name: string3 age: number4}56const url = 'http://myuser.com/somchai'7const somchai = await request<Person>(url)89if (typeof somchai.name === 'string' && typeof somchai.age === 'number') {10 // validate ผ่าน ทำอย่างอื่นต่อ11}
บรรทัดที่ 7 เราต้องระบุ Person ให้ request เพื่อให้ตัวแปร somchai ของเรามีชนิดข้อมูลเป็น Person มิเช่นนั้น somchai.name และ somchai.age จะไม่สามารถเรียกได้ในบรรทัดที่ 9 แท้จริงแล้วบรรทัดที่ 7 somchai ควรมีชนิดข้อมูลเป็น unknown มากกว่าเพราะเรายังไม่รู้ชนิดข้อมูลที่แน่ชัดจนกว่าจะผ่านการตรวจสอบ
เมื่อเปลี่ยนวิธีมาใช้ Type ตัวแปร somchai จะมีชนิดข้อมูลเป็น unknown ส่วนใน match ฟังก์ชันจากอาร์กิวเมนต์แรก TypeScript จะอนุมานตัวแปร data ให้มีโครงสร้างตาม schema โดยอัตโนมัติโดยเราไม่ต้องประกาศชนิดข้อมูล
1const person = t.schema({2 name: t.string,3 age: t.number,4})56person.decode(somchai).match(7 // data มีชนิดข้อมูลเป็น8 // {9 // name: string,10 // age: number,11 // }12 (data) => console.log(data),13 (error) => console.log(error)14)
ตัวเลือกอื่นในการตรวจสอบชนิดข้อมูล runtime
io-ts
บทความนี้อิงหลักการมาจากไลบรารี่ io-ts แต่วิธีการพัฒนาโค้ดของไลบรารี่ไม่เหมือนกัน หากเปลี่ยนวิธีการใช้จาก Type ของบทความเป็น io-ts หน้าตาของโปรแกรมจะเป็นดังนี้
1import * as t from 'io-ts'23const Person = t.type({4 name: t.string,5 age: t.number,6})78// io-ts ไม่ได้ return ชนิดข้อมูล Result9Person.decode(somchai)
Yup
สำหรับ Yup นั้นก็มี TypeOf และ validateSync สำหรับการตรวจสอบค่าเช่นกัน
1import * as yup from 'yup';23const schema = yup.object({4 name: yup.string(),5 age: yup.number(),6});78const Person = yup.TypeOf<typeof schema>;9const data: Person = schema.validateSync(somchai)
สารบัญ
- ข้อผิดพลาดของชนิดข้อมูลช่วง Runtime
- การตรวจสอบชนิดข้อมูลช่วง runtime
- ชนิดข้อมูล Type
- การประกาศและใช้งาน schema
- ชนิดข้อมูล TypeOf
- เมื่อเปรียบเทียบแล้วการใช้ชนิดข้อมูล Type ดีกว่าอย่างไร
- ตัวเลือกอื่นในการตรวจสอบชนิดข้อมูล runtime