พลังของการใช้ number ใน TypeScript ที่จะช่วยให้การประกาศชนิดข้อมูลง่ายขึ้น!
number ในภาษา TypeScript นั้นนอกจากจะใช้เพื่อแทนชนิดข้อมูลตัวเลขแล้ว number ยังสามารถใช้สื่อถึงการวนรอบอาร์เรย์ได้อีกด้วย เพื่อให้เข้าใจสถานการณ์ของการใช้งาน number เพื่อประโยชน์เช่นที่ว่า เรามาดูโจทย์กันก่อนดีกว่าครับ
โจทย์ตั้งต้น
กำหนดให้ตัวแปร appRoutes เก็บออบเจ็กต์ที่มีรูปร่างหน้าตาเช่นนี้
1const appRoutes = [2 { path: '/roles', admin: true },3 { path: '/users', admin: true },4 { path: '/operations', admin: true },5]
ดูจากโค้ดเราก็พอจะเดาได้หละครับว่าชนิดข้อมูลของมันหน้าตาควรเป็นเช่นนี้
1type Route = {2 path: string3 admin: boolean4}
จากตัวแปร appRoutes ถ้าเราอยากได้ค่าของ path จากทุก ๆ route เราสามารถสร้างฟังก์ชันชื่อ getPaths ได้ดังนี้
1const getPaths = (routes: Route[]) => {2 const result = []34 for (const { path } of routes) {5 result.push(path)6 }78 return result9}
โดยการเรียกใช้งานฟังก์ชันดังกล่าวก็ง่ายแสนง่าย เพียงแค่ส่ง appRoutes เข้าไป ผลลัพธ์ที่ออกมาก็จะเป็น ['/roles', '/users', '/operations']
1const paths = getPaths(appRoutes)
ทว่าถ้าเราเอาเมาส์จิ้มไปดูชนิดข้อมูลของตัวแปร paths ที่เราพึ่งสร้าง จะพบว่าชนิดข้อมูลของมันคือ string[]
ช่างบัดซบเสียจริงทำไม TypeScript ถึงไม่เข้าใจวัยรุ่น เราอยากให้มันแสดงชนิดข้อมูลเป็น ("/roles" | "/users" | "/operations")[]
มากกว่า
const assertion
ระหว่างนี้ถ้าเราตรวจสอบชนิดข้อมูลของตัวแปร appRoutes จะพบว่าชนิดข้อมูลของมันเป็นเช่นนี้
1const appRoutes: {2 path: string3 admin: boolean4}[]
เหตุเพราะ path ของแต่ละ Route ท้ายสุดแล้วถูก TypeScript เข้าใจเป็น string จึงไม่แปลกใจที่ผลลัพธ์ของ getPaths จะคืนชนิดข้อมูลออกมาเป็น string[]
เพื่อไม่ให้ความเข้าใจผิดนี้ค้างคาอีกต่อไป เราจะบอก TypeScript ว่าคุณช่วยอย่าขยายประเภทข้อมูลไปเป็นตัวที่ใหม่กว่า
ถ้าฉันระบุว่า path คือ /users แปลว่าฉันต้องการชนิดข้อมูลเป็น literal string คือ /users
ไม่ต้องทะลึ่งขยายไปเป็น string (ทำ Type Widening) วิธีการนี้เราแค่ใส่ as const
คือจบเลยครับ
1const appRoutes = [2 { path: '/roles', admin: true },3 { path: '/users', admin: true },4 { path: '/operations', admin: true },5] as const
ตอนนี้ถ้าเราแอบส่องชนิดข้อมูลของ appRoutes จะพบว่าชนิดข้อมูลใหม่เป็นเช่นนี้
1const appRoutes: readonly [2 {3 readonly path: '/roles'4 readonly admin: true5 },6 {7 readonly path: '/users'8 readonly admin: true9 },10 {11 readonly path: '/operations'12 readonly admin: true13 }14]
path ของเราตอนนี้จะไม่ใช่ string อีกต่อไป ข้อมูลเพิ่มเติมสำหรับ const assertion ศึกษาเพิ่มเติมได้จากที่นี่
เปลี่ยนรูปแบบ Function Signature
ตอนนี้ตัวแปร appRoutes ก็มีส่วนของ path ที่ชนิดข้อมูลถูกต้องแล้ว ทว่าถ้าเราระบุให้ค่าพารามิเตอร์ที่ส่งไปยัง getPaths
มีชิดข้อมูลเป็น routes: Route[]
โดยที่ Route เป็นชนิดข้อมูลที่มี path เป็น string ท้ายสุดแล้วค่า appRoutes ที่ส่งเข้ามา
ก็จะถูกตีตกไปเป็น string อีกรอบอยู่ดี เราจึงควรเปลี่ยนหน้าตาฟังก์ชันของเราใหม่ให้รับค่า generic parameter ชื่อ Routes
ที่ extends มาจาก Route[] การ extends นี้สื่อความว่าค่าที่ส่งมาขอแค่เข้ากันได้กับ Route[] ก็พอ ดังนั้นถ้าเราส่ง appRoutes
ที่ค่าของ path ไม่ใช่ string แต่เป็น literal string (เช่น /users) ที่เข้ากันได้กับ string ฟังก์ชันนี้จึงรับค่าดังกล่าวเข้ามาได้
และไม่ทำการแปลงกลับไปเป็น string อีกต่อไป
1const getPaths = <Routes extends readonly Route[]>(routes: Routes) => {2 const result = []34 for (const { path } of routes) {5 result.push(path)6 }78 return result9}
number กู้ชีวิต
ทว่าโค้ดของ getPaths ในตอนนี้ เราดึงค่า path ออกมาจากแต่ละ route ในแต่ละรอบของการวนลูป เหตุเพราะตัวของฟังก์ชัน getPaths เองยังไม่ทราบจริง ๆ หรอกว่า generic parameter ชื่อ Routes ท้ายสุดแล้วตอนเรียกใช้งานจะส่งค่าเข้ามาเข้าคู่หน้าตาแบบไหน ตัวมันเองเลยยึดถือไปก่อนว่าตนเองก็ควรหน้าตาแบบเดียวกับ Route[] ด้วยความที่ Route มี path เป็น string ดังนั้นแล้ว path แต่ละตัวในแต่ละลูปจึงเป็น string เมื่อนำ path ยัดใส่ตัวแปร result ผ่านเมธอด push จึงเป็นเหตุทำให้ result กลายเป็นชนิดข้อมูล string[] ไปด้วย ท้ายสุดแล้วค่าที่คืนออกจาก getPaths จึงยังคงเป็น string[] เช่นเดิม
ตอนนี้เราจะกำหนดชนิดข้อมูลให้กับตัวแปร result เพื่อป้องกัน TypeScript เข้าใจผิดว่ามันเป็น string[]
1const getPaths = <Routes extends readonly Route[]>(routes: Routes) => {2 const result: Routes[number]['path'][] = []34 for (const { path } of routes) {5 result.push(path)6 }78 return result9}
ตามตัวอย่างโค้ดข้างต้นเราเลือกใส่ Routes[number] ในความหมายที่ว่าเข้าถึงทุก ๆ ช่องของอาร์เรย์ Routes เมื่อเราส่ง appRoutes หน้าตาดังนี้เข้ามาในฟังก์ชัน
1const appRoutes = [2 { path: '/roles', admin: true },3 { path: '/users', admin: true },4 { path: '/operations', admin: true },5] as const
Routes[number] จึงเปรียบเสมือนการเรียก Routes[0] | Routes[1] | Routes[2]
จากนั้นเราทำการต่อยอดด้วยการระบุว่าสนใจเฉพาะค่า (value) ที่มาจากพร็อพเพอร์ตี้ชื่อ path ของแต่ละช่องในอาร์เรย์
Routes[number]['path']
จาก appRoutes ข้างต้นผลลัพธ์จึงเท่ากับ '/route' | '/users' | '/operations'
เราต่างทราบว่า getPaths ต้องคืนค่าเป็น path หลายตัว เราจึงต้องระบุให้ result มีชนิดข้อมูลเป็นอาร์เรย์ ดังนี้
Routes[number]['path'][]
หลังจากผ่านทุกกระบวนท่าแล้วตอนนี้การเรียกใช้ getPaths ของเราก็จะให้ผลลัพธ์ทางชนิดข้อมูลตามที่เราต้องการ
1// ชนิดข้อมูลของ paths คือ ("/roles" | "/users" | "/operations")[]2const paths = getPaths(appRoutes)
ยาวไปขอสั้น ๆ
นอกเหนือจากการใช้ number เพื่อแก้ไขปัญหาดังกล่าวแล้ว เราเพียงเปลี่ยนรูปแบบของฟังก์ชันตามตัวอย่างนี้ก็ได้ผลลัพธ์เช่นเดียวกัน
1const getPaths = <R extends Route>(routes: readonly R[]) => {2 const result: Route['path'][] = []34 for (const { path } of routes) {5 result.push(path)6 }78 return result9}
สารบัญ
- โจทย์ตั้งต้น
- const assertion
- เปลี่ยนรูปแบบ Function Signature
- number กู้ชีวิต
- ยาวไปขอสั้น ๆ