พลังของการใช้ number ใน TypeScript ที่จะช่วยให้การประกาศชนิดข้อมูลง่ายขึ้น!

Nuttavut Thongjor

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

โจทย์ตั้งต้น

กำหนดให้ตัวแปร appRoutes เก็บออบเจ็กต์ที่มีรูปร่างหน้าตาเช่นนี้

TypeScript
1const appRoutes = [
2 { path: '/roles', admin: true },
3 { path: '/users', admin: true },
4 { path: '/operations', admin: true },
5]

ดูจากโค้ดเราก็พอจะเดาได้หละครับว่าชนิดข้อมูลของมันหน้าตาควรเป็นเช่นนี้

TypeScript
1type Route = {
2 path: string
3 admin: boolean
4}

จากตัวแปร appRoutes ถ้าเราอยากได้ค่าของ path จากทุก ๆ route เราสามารถสร้างฟังก์ชันชื่อ getPaths ได้ดังนี้

TypeScript
1const getPaths = (routes: Route[]) => {
2 const result = []
3
4 for (const { path } of routes) {
5 result.push(path)
6 }
7
8 return result
9}

โดยการเรียกใช้งานฟังก์ชันดังกล่าวก็ง่ายแสนง่าย เพียงแค่ส่ง appRoutes เข้าไป ผลลัพธ์ที่ออกมาก็จะเป็น ['/roles', '/users', '/operations']

TypeScript
1const paths = getPaths(appRoutes)

ทว่าถ้าเราเอาเมาส์จิ้มไปดูชนิดข้อมูลของตัวแปร paths ที่เราพึ่งสร้าง จะพบว่าชนิดข้อมูลของมันคือ string[] ช่างบัดซบเสียจริงทำไม TypeScript ถึงไม่เข้าใจวัยรุ่น เราอยากให้มันแสดงชนิดข้อมูลเป็น ("/roles" | "/users" | "/operations")[] มากกว่า

const assertion

ระหว่างนี้ถ้าเราตรวจสอบชนิดข้อมูลของตัวแปร appRoutes จะพบว่าชนิดข้อมูลของมันเป็นเช่นนี้

TypeScript
1const appRoutes: {
2 path: string
3 admin: boolean
4}[]

เหตุเพราะ path ของแต่ละ Route ท้ายสุดแล้วถูก TypeScript เข้าใจเป็น string จึงไม่แปลกใจที่ผลลัพธ์ของ getPaths จะคืนชนิดข้อมูลออกมาเป็น string[]

เพื่อไม่ให้ความเข้าใจผิดนี้ค้างคาอีกต่อไป เราจะบอก TypeScript ว่าคุณช่วยอย่าขยายประเภทข้อมูลไปเป็นตัวที่ใหม่กว่า ถ้าฉันระบุว่า path คือ /users แปลว่าฉันต้องการชนิดข้อมูลเป็น literal string คือ /users ไม่ต้องทะลึ่งขยายไปเป็น string (ทำ Type Widening) วิธีการนี้เราแค่ใส่ as const คือจบเลยครับ

TypeScript
1const appRoutes = [
2 { path: '/roles', admin: true },
3 { path: '/users', admin: true },
4 { path: '/operations', admin: true },
5] as const

ตอนนี้ถ้าเราแอบส่องชนิดข้อมูลของ appRoutes จะพบว่าชนิดข้อมูลใหม่เป็นเช่นนี้

TypeScript
1const appRoutes: readonly [
2 {
3 readonly path: '/roles'
4 readonly admin: true
5 },
6 {
7 readonly path: '/users'
8 readonly admin: true
9 },
10 {
11 readonly path: '/operations'
12 readonly admin: true
13 }
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 อีกต่อไป

TypeScript
1const getPaths = <Routes extends readonly Route[]>(routes: Routes) => {
2 const result = []
3
4 for (const { path } of routes) {
5 result.push(path)
6 }
7
8 return result
9}

number กู้ชีวิต

ทว่าโค้ดของ getPaths ในตอนนี้ เราดึงค่า path ออกมาจากแต่ละ route ในแต่ละรอบของการวนลูป เหตุเพราะตัวของฟังก์ชัน getPaths เองยังไม่ทราบจริง ๆ หรอกว่า generic parameter ชื่อ Routes ท้ายสุดแล้วตอนเรียกใช้งานจะส่งค่าเข้ามาเข้าคู่หน้าตาแบบไหน ตัวมันเองเลยยึดถือไปก่อนว่าตนเองก็ควรหน้าตาแบบเดียวกับ Route[] ด้วยความที่ Route มี path เป็น string ดังนั้นแล้ว path แต่ละตัวในแต่ละลูปจึงเป็น string เมื่อนำ path ยัดใส่ตัวแปร result ผ่านเมธอด push จึงเป็นเหตุทำให้ result กลายเป็นชนิดข้อมูล string[] ไปด้วย ท้ายสุดแล้วค่าที่คืนออกจาก getPaths จึงยังคงเป็น string[] เช่นเดิม

ตอนนี้เราจะกำหนดชนิดข้อมูลให้กับตัวแปร result เพื่อป้องกัน TypeScript เข้าใจผิดว่ามันเป็น string[]

TypeScript
1const getPaths = <Routes extends readonly Route[]>(routes: Routes) => {
2 const result: Routes[number]['path'][] = []
3
4 for (const { path } of routes) {
5 result.push(path)
6 }
7
8 return result
9}

ตามตัวอย่างโค้ดข้างต้นเราเลือกใส่ Routes[number] ในความหมายที่ว่าเข้าถึงทุก ๆ ช่องของอาร์เรย์ Routes เมื่อเราส่ง appRoutes หน้าตาดังนี้เข้ามาในฟังก์ชัน

TypeScript
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 ของเราก็จะให้ผลลัพธ์ทางชนิดข้อมูลตามที่เราต้องการ

TypeScript
1// ชนิดข้อมูลของ paths คือ ("/roles" | "/users" | "/operations")[]
2const paths = getPaths(appRoutes)

ยาวไปขอสั้น ๆ

นอกเหนือจากการใช้ number เพื่อแก้ไขปัญหาดังกล่าวแล้ว เราเพียงเปลี่ยนรูปแบบของฟังก์ชันตามตัวอย่างนี้ก็ได้ผลลัพธ์เช่นเดียวกัน

TypeScript
1const getPaths = <R extends Route>(routes: readonly R[]) => {
2 const result: Route['path'][] = []
3
4 for (const { path } of routes) {
5 result.push(path)
6 }
7
8 return result
9}
สารบัญ

สารบัญ

  • โจทย์ตั้งต้น
  • const assertion
  • เปลี่ยนรูปแบบ Function Signature
  • number กู้ชีวิต
  • ยาวไปขอสั้น ๆ