Runtime Type Checking ด้วยภาษา TypeScript

Nuttavut Thongjor

บทความนี้เป็นเนื้อหาต่อเนื่องจาก บทความ Type Safe Errors ด้วยภาษา TypeScript

ภาษา TypeScript ช่วยให้เราสามารถนิยามรูปร่างข้อมูลของตัวแปรต่าง ๆ ได้ ตัวแปรใด ๆ ที่ทราบชนิดข้อมูลที่แน่ชัดในช่วงคอมไพล์ หากมีการประกาศผิดรูปร่าง TypeScript ย่อมมีความสามารถในการแจ้งเตือนข้อผิดพลาดนั้นให้ทราบได้

TypeScript
1interface Person {
2 name: string
3 age: number
4}
5
6const 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 ตามที่ระบุ

TypeScript
1async function request<Data>(url: string) {
2 // ดึงข้อมูลจาก API ตาม URL ที่ระบุ
3 const res = await fetch(url)
4 // ดึงข้อมูลเป็น JSON จาก Response
5 const data: Data = await res.json()
6
7 return data
8
9 // โค้ดชุดนี้ตัดการจัดการ error ออกไป
10}

สมมติว่าการร้องขอข้อมูลไปที่ myuser.com/somchai จะได้ข้อมูลของ somchai กลับมาเป็นชนิดข้อมูล Person การเรียกใช้งาน request ของเราจึงควรเป็นดังนี้

TypeScript
1const url = 'http://myuser.com/somchai'
2const somchai = await request<Person>(url)
3
4// TypeScript รู้ชนิดข้อมูลของ somchai ว่าเป็น Person
5// เพราะฟังก์ชัน request คืน data กลับมาด้วยค่า generic ที่ส่งเข้ามา
6// (คือชนิดข้อมูล Person)
7somchai.name

จากตัวอย่างที่ยกมา TypeScript ไม่ได้ทราบจริง ๆ หรอกว่า URL ดังกล่าวจะคืนข้อมูลด้วยชนิดข้อมูล Person หรือไม่ หากแต่เราใช้ Generic Parameter (ชื่อ Data) ในการป้อนให้ฟังก์ชัน request คืนชนิดข้อมูลเป็น Generic Parameter ที่รับเข้ามา ระบบจึงเหมือนมโนไปเองว่าตัวแปร somchai เป็นชนิดข้อมูล Person นั่นเอง

จะเกิดอะไรขึ้นถ้า URL ที่ระบุไม่คืนค่ากลับแบบที่เราต้องการ เช่น แต่เดิมควรคืนค่าเป็นชนิดข้อมูล Person แต่แท้จริงแล้วกลับส่งชนิดข้อมูลเป็น { person: Person } แทน

TypeScript
1// ตัวอย่าง response ที่ไม่ได้คาดหวัง แต่ API ส่งคืนมา
2{
3 // มีการครอบทับด้วย person แต่ที่เราคาดหวังไว้ไม่ควรมี
4 "person": {
5 "name": "Somchai",
6 "age": 24
7 }
8}

แน่นอนว่าถ้าผลลัพธ์จาก runtime ไม่ถูกต้องการเรียก somchai.name จากตัวอย่างก่อนหน้าย่อมทำไม่ได้

การตรวจสอบชนิดข้อมูลช่วง runtime

เมื่อผลลัพธ์จาก API อาจไม่ตรงตามคาดหวัง ชนิดข้อมูลที่คืนกลับจากฟังก์ชัน request จึงไม่ควรเป็น Person แต่ควรเป็น unknown ที่สื่อความว่ายังไม่รู้ชนิดข้อมูล runtime ณ ขณะนั้น

TypeScript
1const url = 'http://myuser.com/somchai'
2const somchai = await request(url)
3
4// somchai เป็นชนิดข้อมูล unknown
5// เราจึงยังเรียกใช้ name ไม่ได้
6somchai.name // ทำไม่ได้

ก่อนที่ตัวแปร somchai จะถูกนำไปใช้ เราควรตรวจสอบชนิดข้อมูลที่แท้จริงของมันเสียก่อน สิ่งหนึ่งที่ในบทความนี้จะทำคือการนิยามโครงสร้างของข้อมูลที่คาดหวังขึ้นมา

TypeScript
1import * as t from './runtime-types'
2
3const person = t.schema({
4 name: t.string,
5 age: t.number,
6})

ไฟล์ ./runtime-types เป็นสิ่งที่เราจะสร้างขึ้นในครึ่งหลังของบทความ สำหรับตัวอย่างนี้เป็นการประกาศ person มีโครงสร้างที่คาดหวังคือต้องเป็นออบเจ็กต์ที่ประกอบด้วยค่า name เป็น string และ age เป็น number

ภายหลังการนิยามโครงสร้างแล้วตัวแปร somchai จะยังนำไปใช้ไม่ได้เพราะเราไม่ทราบว่า API ได้คืน somchai กลับมาแบบที่เราคาดหวังหรือไม่ เราต้องนำตัวแปร somchai มาทำการเทียบกับโครงสร้าง person ก่อนดังนี้

TypeScript
1const url = 'http://myuser.com/somchai'
2const somchai = await request(url)
3
4person.decode(somchai)

ตัวอย่างข้างต้น person ที่เป็นตัวแปรสำหรับนิยามโครงสร้างจะมีเมธอด decode ไว้รับค่าที่มาจาก runtime (ในตัวอย่างนี้คือ somchai ที่เป็นค่าจาก API) แล้วทำการวิเคราะห์โครงสร้างว่ามีชนิดข้อมูลถูกต้องหรือไม่

เมธอด decode จะคืนกลับเป็นชนิดข้อมูล Result ทำให้เราสามารถใช้ match เพื่อสร้างสองทางเลือกได้ หากข้อมูลมีโครงสร้างที่ถูกต้องอาร์กิวเมนต์ตัวแรกที่ส่งให้กับ match จะถูกเรียก ในทางกลับกันเมื่อโครงสร้างผิดฟังก์ชันที่สองที่ส่งให้กับ match จะถูกเรียกแทน

TypeScript
1person.decode(somchai).match(
2 // data คือค่าของ somchai ที่มีชนิดข้อมูลเป็น Person
3 (data) => console.log(data),
4 // ฟังก์ชันนี้ถูกเรียกเมื่อ somchai ไม่ได้มีโครงสร้างเป็น person
5 (error) => console.log(error)
6)

ชนิดข้อมูล Type

ชนิดข้อมูลใหม่ที่นิยามนี้อยู่ภายใต้ไฟล์ ./runtime-types.ts เวลาเรียกใช้งานจะเป็น

TypeScript
1import * 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

TypeScript
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 หรือไม่นั่นเอง

TypeScript
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 มีหน้าตาเป็นดังนี้

TypeScript
1export class Type<T> {
2 constructor(
3 readonly is: Is<T>,
4 readonly createErrMessage: (value: unknown) => string
5 ) {}
6}

เราอยากให้ชนิดข้อมูล Type มีเมธอด decode สำหรับการตรวจสอบค่าด้วย เราจะสร้างเมธอด decode ให้มีหน้าตาอย่างไรดี?

สมมติให้เราต้องการรับข้อมูลจากผู้ใช้งานเป็นอายุ (age) เราจึงออกแบบ t.number ของเราให้สามารถใช้ decode ได้ดังนี้ (ในตัวอย่างนี้ค่า input คือเลข 24)

TypeScript
1// นิยามโครงสร้างว่า input ต้องเป็น number
2const age = t.number
3
4age.decode(24).match(
5 // n มีชนิดข้อมูลเป็น number
6 (n) => console.log(n),
7 (err) => console.error(err)
8)

จากตัวอย่างการใช้งาน decode ของเราต้องรับพารามิเตอร์เป็นชนิดข้อมูล unknown เพราะเรายังไม่ทราบว่า ณ จังหวะที่ผู้ใช้งานระบุค่าเข้ามานั้นเขาใส่ชนิดข้อมูลใดกันแน่ ส่วนค่าที่คืนกลับจากเมธอด decode ต้องเป็น Result เพื่อให้สามารถเรียก match ต่อได้

TypeScript
1type Is<T> = (u: unknown) => u is T
2
3export class Type<T> {
4 // ตัวแปรนี้ใช้อ้างอิงภายหลังถึงชนิดข้อมูลที่แท้จริงของข้อมูลจาก runtime
5 value!: T
6
7 constructor(
8 readonly is: Is<T>,
9 readonly createErrMessage: (value: unknown) => string
10 ) {}
11
12 decode(value: unknown): Result<T, string> {
13 const isValid = this.is(value)
14
15 return isValid ? ok(value) : err(this.createErrMessage(value))
16 }
17}

การประกาศและใช้งาน schema

เป้าหมายสูงสุดคือต้องสามารถนิยามโครงสร้างของข้อมูลระดับออบเจ็กต์ได้ผ่าน schema

TypeScript
1import * as t from './runtime-types'
2
3const person = t.schema({
4 name: t.string,
5 age: t.number,
6})

นั่นรวมถึง schema ต้องสามารถ decode ข้อมูลได้อย่างถูกต้องด้วย

TypeScript
1person.decode(somchai).match(
2 (data) => console.log(data),
3 (error) => console.log(error)
4)

ในกรณีที่ somchai มีค่าข้อมูลที่ไม่ถูกต้อง error ต้องแจ้งเตือนได้ว่าแต่ละพร็อพเพอร์ตี้ของ somchai มีข้อผิดพลาดอย่างไร

TypeScript
1// ออบเจ็กต์ที่ส่งใน decode
2// ให้เสมือนหนึ่งเป็นผลลัพธ์ที่ผิดพลาดจาก API
3person
4 .decode({
5 person: {
6 name: 'Somchai',
7 age: '24',
8 },
9 })
10 .match(
11 (data) => console.log(data),
12 (error) => console.log(error)
13 )
14
15// error ที่ได้รับคือ
16// {
17// "name": "undefined is not string",
18// "age": "undefined is not number"
19// }

สำหรับ schema มีวิธีการเขียนโค้ดดังนี้

TypeScript
1export type TypeOf<T extends Type<unknown>> = T['value']
2
3type Schema<T extends Record<string, Type<unknown>>> = {
4 [K in keyof T]: TypeOf<T[K]>
5}
6
7type TypeOfSchema<T extends Record<string, Type<unknown>>> = {
8 [K in keyof T]: TypeOf<T[K]>
9}
10
11const isObject = (u: unknown): u is Record<string, unknown> => {
12 return typeof u === 'object' && u !== null
13}
14
15const schema = <T extends Record<string, Type<unknown>>>(mapping: T) => {
16 const errMessages: Record<string, unknown> = {}
17
18 return new Type(
19 (value: unknown): value is TypeOfSchema<T> => {
20 if (!isObject(value)) return false
21
22 let isValid = true
23
24 for (const [propName, validator] of Object.entries(mapping)) {
25 const result = validator.decode(value[propName])
26
27 if (result.isErr()) {
28 isValid = false
29 errMessages[propName] = result.error
30 }
31 }
32
33 return isValid
34 },
35 () => JSON.stringify(errMessages)
36 )
37}

ชนิดข้อมูล TypeOf

ในหัวข้อก่อนหน้านี้เราได้สร้างชนิดข้อมูล TypeOf ไว้สำหรับคืนกลับเป็นชนิดข้อมูลที่คาดหวังจาก runtime

TypeScript
1type TypeOf<T extends Type<unknown>> = T['value']

เพื่อให้ได้ชนิดข้อมูลที่แท้จริงเราสามารถใช้ TypeOf ได้ดังนี้

TypeScript
1const person = t.schema({
2 name: t.string,
3 age: t.number,
4})
5
6type Person = t.TypeOf<typeof person>
7
8// มีค่าเท่ากับ
9// type Person = {
10// name: string;
11// age: string;
12// }

เมื่อเปรียบเทียบแล้วการใช้ชนิดข้อมูล Type ดีกว่าอย่างไร

หากเราไม่ได้ใช้ Type เราจำเป็นต้องนิยามชนิดข้อมูลของ runtime ขึ้นมาก่อน จากนั้นจึงทำการตรวจสอบความถูกต้องของข้อมูลภายหลัง เช่น

TypeScript
1interface Person {
2 name: string
3 age: number
4}
5
6const url = 'http://myuser.com/somchai'
7const somchai = await request<Person>(url)
8
9if (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 โดยอัตโนมัติโดยเราไม่ต้องประกาศชนิดข้อมูล

TypeScript
1const person = t.schema({
2 name: t.string,
3 age: t.number,
4})
5
6person.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 หน้าตาของโปรแกรมจะเป็นดังนี้

TypeScript
1import * as t from 'io-ts'
2
3const Person = t.type({
4 name: t.string,
5 age: t.number,
6})
7
8// io-ts ไม่ได้ return ชนิดข้อมูล Result
9Person.decode(somchai)

Yup

สำหรับ Yup นั้นก็มี TypeOf และ validateSync สำหรับการตรวจสอบค่าเช่นกัน

TypeScript
1import * as yup from 'yup';
2
3const schema = yup.object({
4 name: yup.string(),
5 age: yup.number(),
6});
7
8const Person = yup.TypeOf<typeof schema>;
9const data: Person = schema.validateSync(somchai)
สารบัญ

สารบัญ

  • ข้อผิดพลาดของชนิดข้อมูลช่วง Runtime
  • การตรวจสอบชนิดข้อมูลช่วง runtime
  • ชนิดข้อมูล Type
  • การประกาศและใช้งาน schema
  • ชนิดข้อมูล TypeOf
  • เมื่อเปรียบเทียบแล้วการใช้ชนิดข้อมูล Type ดีกว่าอย่างไร
  • ตัวเลือกอื่นในการตรวจสอบชนิดข้อมูล runtime