Type Safe Errors ด้วยภาษา TypeScript

Nuttavut Thongjor

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

TypeScript
1const getUserByEmail = (email: string): Promise<User> => {
2 if (!isEmail(email)) {
3 throw new Error('Invalid email format')
4 }
5
6 // โค้ดส่วนอื่น ๆ
7 return fetchUserByEmail(email)
8}

จะเกิดอะไรขึ้นเมื่อจังหวะของ runtime ฟังก์ชันดังกล่าวถูกเติมเต็มด้วยการเรียกใช้ผ่าน getUserByEmail('abc') ที่เราทราบอยู่แล้วว่า abc ไม่ใช่อีเมล์อย่างแน่นอน

ถ้าโค้ดนี้ถูกห่อหุ้มด้วย try/catch ซักรอบเพื่อดักจับข้อผิดพลาดก็คงไม่เกิดปัญหาอะไรขึ้นมา แต่ประเด็นคือบางครั้งเราก็ใช้ฟังก์ชันที่ผู้อื่นเขียนจนลืมไปว่าฟังก์ชันนั้น ๆ ก็อาจมีข้อผิดพลาดเกิดขึ้นได้ นำมาสู่การลืมตรวจจับข้อผิดพลาดที่อาจเกิดขึ้นจากการเรียกใช้ฟังก์ชัน

ทำอย่างไรหละการเรียกใช้งานฟังก์ชันดังกล่าวจะปลอดภัยมากขึ้นแม้จะจัดการหรือไม่จัดการข้อผิดพลาดก็ตาม?

การสร้างชนิดข้อมูล Result

เรากล่าวได้ว่าการ throw ข้อผิดพลาดนั้นไม่ "type safe" เพราะเราไม่สามารถการันตีชนิดข้อมูลได้ตั้งแต่ตอนคอมไพล์ เมื่อเป็นเช่นนี้เราจึงต้องนิยามชนิดข้อมูลใหม่เพื่อให้มีความปลอดภัยมากขึ้นในการเรียกใช้งาน ชนิดข้อมูลใหม่นี้ต้องห่อหุ้มสองสิ่งต่อไปนี้คือ ข้อมูลเมื่อโปรแกรมทำงานถูกต้อง (success value) และข้อผิดพลาดอันเกิดจากการทำงานที่ผิด

จากโค้ดข้างต้นหากต้องการให้การเรียกใช้ฟังก์ชัน getUserByEmail มีความปลอดภัยต่อการจัดการข้อผิดพลาดมากขึ้น ฟังก์ชันดังกล่าวต้องคืนชนิดข้อมูลใหม่ โดยชนิดข้อมูลนี้ต้องบรรจุทั้งข้อมูลของการทำงานและข้อผิดพลาดที่อาจเกิดขึ้นได้

เพื่อให้บรรลุจุดประสงค์ดังกล่าว ในบทความนี้เราจะทำการสร้างชนิดข้อมูลใหม่ชื่อ Result โดยชนิดข้อมูลดังกล่าวมีหน้าตาดังนี้

TypeScript
1type Result<S, E> = Ok<S, E> | Err<S, E>

ชนิดข้อมูล Result นั้นสามารถเป็นชนิดข้อมูล Ok หรือ Err ก็ได้ ทั้งสามชนิดข้อมูลนี้มีการรับ Generic Parameters เป็น S ที่หมายถึงค่าข้อมูลเมื่อการทำงานสำเร็จ และ E ที่หมายถึงข้อผิดพลาดในกรณีที่การทำงานนั้นล้มเหลว

ก่อนที่เราจะไปดูการสร้างชนิดข้อมูล Ok และ Err เราจะมาทำความเข้าใจก่อนว่าทั้งสามชนิดข้อมูลนั้นมีเมธอดที่สามารถถูกเรียกได้ คือ isOk และ isErr กรณีที่เรียก isOk จาก Result หาก Result นั้นทำงานสำเร็จปราศจากข้อผิดพลาดผลลัพธ์ของฟังก์ชันจะเป็น true ในทางตรงข้ามฟังก์ชันนี้จะคืน false เมื่อการทำงานล้มเหลว

TypeScript
1result.isOk() // true เมื่อทำงานสำเร็จ
2result.isErr() // true เมื่อพบข้อผิดพลาด

ภายใต้ประโยคเงื่อนไขเมื่อเรียก result.isOk() หากการทำงานนั้นสมบูรณ์ result จะถูกอนุมานให้เป็นชนิดข้อมูล Ok สำหรับข้อผิดพลาดก็เช่นกันที่เมื่อเรียก result.isErr() TypeScript จะอนุมานให้เป็นชนิดข้อมูล Err

TypeScript
1// กำหนดให้ result เป็นปนะเภทมีข้อผิดพลาด
2if (result.isErr()) {
3 // true
4 // result ภายใต้ประโยคเงื่อนไขนี้เป็นชนิดข้อมูล Err
5} else {
6 // result ภายใต้ประโยคเงื่อนไขนี้เป็นชนิดข้อมูล Ok
7}

การสร้างชนิดข้อมูล Ok และ Err

เพื่อให้บรรลุเงื่อนไขของการเรียก result ข้างต้น เราต้องทำการสร้างคลาส Ok และ Err ดังนี้

TypeScript
1interface ActsAsResult<S, E> {
2 isOk: () => boolean
3 isErr: () => boolean
4}
5
6class Ok<S, E> implements ActsAsResult<S, E> {
7 constructor(readonly value: S) {}
8
9 isOk(): this is Ok<S, E> {
10 return true
11 }
12
13 isErr() {
14 return false
15 }
16}
17
18class Err<S, E> implements ActsAsResult<S, E> {
19 constructor(readonly error: E) {}
20
21 isOk() {
22 return false
23 }
24
25 isErr(): this is Err<S, E> {
26 return true
27 }
28}
29
30type Result<S, E> = Ok<S, E> | Err<S, E>

ชนิดข้อมูล Ok จะทำการเก็บผลลัพธ์ที่ทำงานเสร็จสมบูรณ์ภายใต้ออบเจ็กต์ด้วยพร็อพเพอร์ตี้ชื่อ value ในขณะที่ Err ทำการจัดเก็บข้อผิดพลาดผ่านพร็อพเพอร์ตี้ error

การเรียกใช้คลาส Ok และ Err สามารถสร้าง instance ของออบเจ็กต์ได้ตามปกติ

TypeScript
1const ok = new Ok('Yay!')
2const error = new Err(new Error('My Error'))

ตอนนี้เรามีวิธีสร้างข้อมูลสำหรับชนิดข้อมูล Ok และ Err แล้ว หากแต่ยังขาดวิธีสร้างชนิดข้อมูล Result อยู่ นอกจากนี้การสร้างชนิดข้อมูล Ok และ Err ค่อนข้างยุ่งยากเพราะเราต้องทำการสร้าง instance ใหม่ผ่านการ new ทุกครั้ง เมื่อเป็นเช่นนี้เราจึงจะทำการสร้างฟังก์ชันตัวกลางชื่อ ok และ err เพื่อทำการคืนกลับด้วยชนิดข้อมูล Result แทน ดังนี้

TypeScript
1const ok = <S, E>(value: S): Result<S, E> => new Ok(value)
2const err = <S, E>(error: E): Result<S, E> => new Err(error)

ตัวอย่างการสร้างข้อมูลและเรียกใช้งาน เช่น

TypeScript
1const success = ok('Success!')
2const failure = err(new Error('Failure!'))

ตอนนี้เครื่องมือต่าง ๆ ของเราครบถ้วนแล้ว เราสามารถจัดการข้อผิดพลาดของเราได้อย่างประสิทธิภาพมากขึ้น ถ้าเราทำการสร้างข้อผิดพลาดขึ้นมาผ่าน err เนื่องจากฟังก์ชันนี้คืนกลับด้วยชนิดข้อมูล Result ที่อาจเป็น Ok หรือ Err ก็ได้ Result จึงไม่สามารถเข้าถึงพร็อพเพอร์ตี้ value (ของ Ok) หรือ error (ของ Err) ได้โดยตรง

TypeScript
1const failure = err(new Error('Failure!'))
2
3failure.error // เรียกไม่ได้
4
5const success = ok('Success!')
6
7success.value // เรียกไม่ได้

เราจะต้องทำการตรวจสอบก่อนเสมอว่า result นั้นเป็น Ok หรือ Err จึงจะสามารถนำค่าภายในมาใช้ต่อได้

TypeScript
1const success = ok('Success!')
2
3if (success.isOk()) {
4 // TypeScript จะอนุมานให้ success มีชนิดข้อมูลเป็น Ok
5 console.log(success.value)
6}

นอกจากความรัดกุมในการเรียกใช้งานแล้ว หากเราลืมที่จะจัดการข้อผิดพลาดใช่ช่วง runtime ก็ยังคงสามารถทำงานได้ต่อไป ผิดกับการโยนข้อผิดพลาดผ่าน throw ที่เมื่อเราลืมใช้ try/catch โปรแกรมของเราอาจยุติการทำงานได้

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

TypeScript
1const getUserByEmail = async (email: string): Promise<Result<User, Error>> => {
2 if (!isEmail(email)) {
3 return err(new Error('Invalid email format'))
4 }
5
6 // โค้ดส่วนอื่น ๆ
7 const user = await fetchUserByEmail(email)
8
9 return ok(user)
10}

ตัวอย่างการเรียกใช้งานเป็นดังนี้

TypeScript
1const result = await getUserByEmail('my-email')
2
3if (result.isOk()) {
4 // เรียก result.value เพื่อนำค่า user ไปใช้งานต่อ
5} else {
6 // เรียก result.error เพื่อนำข้อผิดพลาดไปประมวลผลต่อ
7}

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

การ Match เพื่อดักจับการทำงานของทั้ง Ok และ Err

บางครั้งเราก็ไม่อยากใช้ประโยค if/else มาทำการตรวจเช็คความเป็นชนิดข้อมูล Ok หรือ Err ทุกครั้งก่อนดำเนินการอื่น จะดีกว่าไหมหากเราสร้างเมธอด match ที่รับพารามิเตอร์สองค่า ค่าแรกคือฟังก์ชันที่จะถูกเรียกพร้อมส่งค่า value เข้ามาในฟังก์ชันเมื่อ result นั้นเป็น Ok และพารามิเตอร์ตัวที่สองคือฟังก์ชันที่รับ error เมื่อ result นั้นเป็น Err หากฟังก์ชันนี้เกิดขึ้นจริงวิธีการเรียกใช้งานจะเป็นดังนี้

TypeScript
1const result = await getUserByEmail('my-email')
2
3result.match(
4 (user) => console.log(user), // value คือ user
5 (error) => console.log(error)
6)

เราสามารถสร้างเมธอด match ไว้ในแต่ละคลาสของ Ok และ Err ดังนี้

TypeScript
1interface ActsAsResult<S, E> {
2 isOk: () => boolean
3 isErr: () => boolean
4 match: <R>(ok: (value: S) => R, err: (error: E) => R) => R
5}
6
7class Ok<S, E> implements ActsAsResult<S, E> {
8 constructor(readonly value: S) {}
9
10 isOk(): this is Ok<S, E> {
11 return true
12 }
13
14 isErr() {
15 return false
16 }
17
18 match<R>(ok: (value: S) => R, _err: (error: E) => R) {
19 return ok(this.value)
20 }
21}
22
23class Err<S, E> implements ActsAsResult<S, E> {
24 constructor(readonly error: E) {}
25
26 isOk() {
27 return false
28 }
29
30 isErr(): this is Err<S, E> {
31 return true
32 }
33
34 match<R>(_ok: (value: S) => R, err: (error: E) => R) {
35 return err(this.error)
36 }
37}

neverthrow

หากคิดว่าการสร้างชนิดข้อมูล Result ด้วยตนเองเป็นเรื่องยาก เรามีไลบรารี่อย่าง neverthrow ที่ช่วยกู้ชีพให้การจัดการข้อผิดพลาดเป็นไปอย่าง Type Safe

สารบัญ

สารบัญ

  • การสร้างชนิดข้อมูล Result
  • การสร้างชนิดข้อมูล Ok และ Err
  • การ Match เพื่อดักจับการทำงานของทั้ง Ok และ Err
  • neverthrow