Type Safe Errors ด้วยภาษา TypeScript
เป็นเรื่องปกติในชีวิตประจำวันที่เราต้องจัดการกับข้อผิดพลาดในโปรแกรม แต่จะทำอย่างไรหละเพื่อให้การเรียกใช้งานฟังก์ชันที่อาจโยนข้อผิดพลาดออกมานั้นถูกเรียกใช้งานได้อย่างปลอดภัยมากขึ้น
1const getUserByEmail = (email: string): Promise<User> => {2 if (!isEmail(email)) {3 throw new Error('Invalid email format')4 }56 // โค้ดส่วนอื่น ๆ7 return fetchUserByEmail(email)8}
จะเกิดอะไรขึ้นเมื่อจังหวะของ runtime ฟังก์ชันดังกล่าวถูกเติมเต็มด้วยการเรียกใช้ผ่าน getUserByEmail('abc')
ที่เราทราบอยู่แล้วว่า abc ไม่ใช่อีเมล์อย่างแน่นอน
ถ้าโค้ดนี้ถูกห่อหุ้มด้วย try/catch ซักรอบเพื่อดักจับข้อผิดพลาดก็คงไม่เกิดปัญหาอะไรขึ้นมา แต่ประเด็นคือบางครั้งเราก็ใช้ฟังก์ชันที่ผู้อื่นเขียนจนลืมไปว่าฟังก์ชันนั้น ๆ ก็อาจมีข้อผิดพลาดเกิดขึ้นได้ นำมาสู่การลืมตรวจจับข้อผิดพลาดที่อาจเกิดขึ้นจากการเรียกใช้ฟังก์ชัน
ทำอย่างไรหละการเรียกใช้งานฟังก์ชันดังกล่าวจะปลอดภัยมากขึ้นแม้จะจัดการหรือไม่จัดการข้อผิดพลาดก็ตาม?
การสร้างชนิดข้อมูล Result
เรากล่าวได้ว่าการ throw ข้อผิดพลาดนั้นไม่ "type safe" เพราะเราไม่สามารถการันตีชนิดข้อมูลได้ตั้งแต่ตอนคอมไพล์ เมื่อเป็นเช่นนี้เราจึงต้องนิยามชนิดข้อมูลใหม่เพื่อให้มีความปลอดภัยมากขึ้นในการเรียกใช้งาน ชนิดข้อมูลใหม่นี้ต้องห่อหุ้มสองสิ่งต่อไปนี้คือ ข้อมูลเมื่อโปรแกรมทำงานถูกต้อง (success value) และข้อผิดพลาดอันเกิดจากการทำงานที่ผิด
จากโค้ดข้างต้นหากต้องการให้การเรียกใช้ฟังก์ชัน getUserByEmail มีความปลอดภัยต่อการจัดการข้อผิดพลาดมากขึ้น ฟังก์ชันดังกล่าวต้องคืนชนิดข้อมูลใหม่ โดยชนิดข้อมูลนี้ต้องบรรจุทั้งข้อมูลของการทำงานและข้อผิดพลาดที่อาจเกิดขึ้นได้
เพื่อให้บรรลุจุดประสงค์ดังกล่าว ในบทความนี้เราจะทำการสร้างชนิดข้อมูลใหม่ชื่อ Result โดยชนิดข้อมูลดังกล่าวมีหน้าตาดังนี้
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 เมื่อการทำงานล้มเหลว
1result.isOk() // true เมื่อทำงานสำเร็จ2result.isErr() // true เมื่อพบข้อผิดพลาด
ภายใต้ประโยคเงื่อนไขเมื่อเรียก result.isOk()
หากการทำงานนั้นสมบูรณ์ result จะถูกอนุมานให้เป็นชนิดข้อมูล Ok
สำหรับข้อผิดพลาดก็เช่นกันที่เมื่อเรียก result.isErr()
TypeScript จะอนุมานให้เป็นชนิดข้อมูล Err
1// กำหนดให้ result เป็นปนะเภทมีข้อผิดพลาด2if (result.isErr()) {3 // true4 // result ภายใต้ประโยคเงื่อนไขนี้เป็นชนิดข้อมูล Err5} else {6 // result ภายใต้ประโยคเงื่อนไขนี้เป็นชนิดข้อมูล Ok7}
การสร้างชนิดข้อมูล Ok และ Err
เพื่อให้บรรลุเงื่อนไขของการเรียก result ข้างต้น เราต้องทำการสร้างคลาส Ok และ Err ดังนี้
1interface ActsAsResult<S, E> {2 isOk: () => boolean3 isErr: () => boolean4}56class Ok<S, E> implements ActsAsResult<S, E> {7 constructor(readonly value: S) {}89 isOk(): this is Ok<S, E> {10 return true11 }1213 isErr() {14 return false15 }16}1718class Err<S, E> implements ActsAsResult<S, E> {19 constructor(readonly error: E) {}2021 isOk() {22 return false23 }2425 isErr(): this is Err<S, E> {26 return true27 }28}2930type Result<S, E> = Ok<S, E> | Err<S, E>
ชนิดข้อมูล Ok จะทำการเก็บผลลัพธ์ที่ทำงานเสร็จสมบูรณ์ภายใต้ออบเจ็กต์ด้วยพร็อพเพอร์ตี้ชื่อ value ในขณะที่ Err ทำการจัดเก็บข้อผิดพลาดผ่านพร็อพเพอร์ตี้ error
การเรียกใช้คลาส Ok และ Err สามารถสร้าง instance ของออบเจ็กต์ได้ตามปกติ
1const ok = new Ok('Yay!')2const error = new Err(new Error('My Error'))
ตอนนี้เรามีวิธีสร้างข้อมูลสำหรับชนิดข้อมูล Ok และ Err แล้ว หากแต่ยังขาดวิธีสร้างชนิดข้อมูล Result อยู่ นอกจากนี้การสร้างชนิดข้อมูล Ok และ Err ค่อนข้างยุ่งยากเพราะเราต้องทำการสร้าง instance ใหม่ผ่านการ new ทุกครั้ง เมื่อเป็นเช่นนี้เราจึงจะทำการสร้างฟังก์ชันตัวกลางชื่อ ok และ err เพื่อทำการคืนกลับด้วยชนิดข้อมูล Result แทน ดังนี้
1const ok = <S, E>(value: S): Result<S, E> => new Ok(value)2const err = <S, E>(error: E): Result<S, E> => new Err(error)
ตัวอย่างการสร้างข้อมูลและเรียกใช้งาน เช่น
1const success = ok('Success!')2const failure = err(new Error('Failure!'))
ตอนนี้เครื่องมือต่าง ๆ ของเราครบถ้วนแล้ว เราสามารถจัดการข้อผิดพลาดของเราได้อย่างประสิทธิภาพมากขึ้น ถ้าเราทำการสร้างข้อผิดพลาดขึ้นมาผ่าน err เนื่องจากฟังก์ชันนี้คืนกลับด้วยชนิดข้อมูล Result ที่อาจเป็น Ok หรือ Err ก็ได้ Result จึงไม่สามารถเข้าถึงพร็อพเพอร์ตี้ value (ของ Ok) หรือ error (ของ Err) ได้โดยตรง
1const failure = err(new Error('Failure!'))23failure.error // เรียกไม่ได้45const success = ok('Success!')67success.value // เรียกไม่ได้
เราจะต้องทำการตรวจสอบก่อนเสมอว่า result นั้นเป็น Ok หรือ Err จึงจะสามารถนำค่าภายในมาใช้ต่อได้
1const success = ok('Success!')23if (success.isOk()) {4 // TypeScript จะอนุมานให้ success มีชนิดข้อมูลเป็น Ok5 console.log(success.value)6}
นอกจากความรัดกุมในการเรียกใช้งานแล้ว หากเราลืมที่จะจัดการข้อผิดพลาดใช่ช่วง runtime ก็ยังคงสามารถทำงานได้ต่อไป ผิดกับการโยนข้อผิดพลาดผ่าน throw ที่เมื่อเราลืมใช้ try/catch โปรแกรมของเราอาจยุติการทำงานได้
ย้อนกลับมาที่โปรแกรมตั้งต้นของเราคือฟังก์ชัน getUserByEmail
เมื่อเราทำการเปลี่ยนฟังก์ชันนี้ให้คืนกลับด้วยชนิดข้อมูล Result
โค้ดใหม่ที่ได้จะเป็นดังนี้
1const getUserByEmail = async (email: string): Promise<Result<User, Error>> => {2 if (!isEmail(email)) {3 return err(new Error('Invalid email format'))4 }56 // โค้ดส่วนอื่น ๆ7 const user = await fetchUserByEmail(email)89 return ok(user)10}
ตัวอย่างการเรียกใช้งานเป็นดังนี้
1const result = await getUserByEmail('my-email')23if (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 หากฟังก์ชันนี้เกิดขึ้นจริงวิธีการเรียกใช้งานจะเป็นดังนี้
1const result = await getUserByEmail('my-email')23result.match(4 (user) => console.log(user), // value คือ user5 (error) => console.log(error)6)
เราสามารถสร้างเมธอด match ไว้ในแต่ละคลาสของ Ok และ Err ดังนี้
1interface ActsAsResult<S, E> {2 isOk: () => boolean3 isErr: () => boolean4 match: <R>(ok: (value: S) => R, err: (error: E) => R) => R5}67class Ok<S, E> implements ActsAsResult<S, E> {8 constructor(readonly value: S) {}910 isOk(): this is Ok<S, E> {11 return true12 }1314 isErr() {15 return false16 }1718 match<R>(ok: (value: S) => R, _err: (error: E) => R) {19 return ok(this.value)20 }21}2223class Err<S, E> implements ActsAsResult<S, E> {24 constructor(readonly error: E) {}2526 isOk() {27 return false28 }2930 isErr(): this is Err<S, E> {31 return true32 }3334 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