มีอะไรใหม่บ้างใน TypeScript 4.6

Nuttavut Thongjor

TypeScript 4.6 ได้ถูกปล่อยออกมาเรียบร้อยแล้ว รอบนี้ไม่ได้มีการเพิ่ม Syntax อะไรใหม่ ๆ แต่ส่วนใหญ่จะเน้นไปทางการปรับปรุงตัวภาษาให้เข้าใจชนิดข้อมูลจากรูปแบบการเขียนต่าง ๆ มากยิ่งขึ้น

การปรับปรุงเพื่อวิเคราะห์การทำ destructure ภายใต้ discriminated unions

ปัญหานี้เป็นเรื่องคลาสสิกชวนปวดตับของ TypeScript เมื่อต้องทำ destructure ควบคู่กับ Discriminating Unions

ในเวอร์ชันก่อนหน้า TypeScript มีปัญหากับการทำ destructure ตัวแปรจากออบเจ็กต์ โดย TypeScript จะมองว่า เมื่อตัวแปรนั้นถูก destructure แล้วย่อมเป็นอิสระจากออบเจ็กต์ดังกล่าวโดยสิ้นเชิง

ฟังดูก็สมเหตุผลนะ แต่ปัญหามันก็มีอยู่บ้างถ้าการทำ destructure นั้นดันไปใช้ควบคู่กับ Discriminant Property

TypeScript
1type Action =
2 | { type: 'CREATE_USER'; payload: { name: string; email: string } }
3 | {
4 type: 'EDIT_USER';
5 payload: { id: number; name?: string; email?: string };
6 };
7
8function handleAction(action: Action) {
9 const { type, payload } = action;
10
11 switch (type) {
12 case 'CREATE_USER':
13 // create user
14 return;
15 case 'EDIT_USER':
16 // update user
17 console.log(payload.id);
18 return;
19 }
20}

จากตัวอย่างข้างต้นชนิดข้อมูล Action นั้นสามารถเป็นออบเจ็กต์ที่มี type เป็น CREATE_USER หรือ EDIT_USER ก็ได้ ทั้งสองต่างมีส่วนของ payload แต่เฉพาะ EDIT_USER เท่านั้นที่จะมีส่วนของ id ใน payload นั้น

เมื่อบรรทัดที่ 3 เราทำการ destructure ได้ตัวแปร type และ payload เมื่อตัวแปรทั้งสองถูกแยกออกจาก action คอมไพเลอร์จึงให้ พวกมันเป็นอิสระจาก action ด้วย เมื่อต้องคงความถูกต้องของชนิดข้อมูล payload ชนิดข้อมูลสำหรับ payload จึงถูกกำหนดให้เป็นได้ทั้ง payload ที่มาจาก CREATE_USER และ EDIT_USER ดังนี้

TypeScript
1const payload:
2 | {
3 name: string;
4 email: string;
5 }
6 | {
7 id: number;
8 name?: string;
9 email?: string;
10 };

จากชนิดข้อมูลของ payload จะสังเกตได้ว่าพร็อพเพอร์ตี้ id นั้นปรากฏเฉพาะโครงสร้างของ { id: number, name?: string, email?: string } แต่ไม่ปรากฎในโครงสร้างของ { name?: string, email?: string } TypeScript จึงไม่อนุญาติให้เข้าถึง id ของ payload ได้โดยตรง เพราะการันตีไม่ได้ว่า id จะปรากฎทุกครั้งนั่นเอง เหตุนี้จึงทำให้บรรทัดที่ 17 เกิดข้อผิดพลาดได้ว่า

Code
1Property 'id' does not exist on type '{ name: string; email: string; } | { id: number; name?: string | undefined; email?: string | undefined; }'.
2 Property 'id' does not exist on type '{ name: string; email: string; }'

ก่อนหน้านี้เราจึงเลี่ยงปัญหานี้ด้วยการเรียก payload.id โดยตรงจาก action ภายใต้ case ของ EDIT_USER เมื่ออยู่ภายใต้ EDIT_USER ตัวแปลภาษาย่อมเข้าใจได้ว่า payload ของ action ภายใต้ EDIT_USER นี้ย่อมต้องมี id นั่นเพราะเราไม่ได้แยกตัวแปรให้อิสระจากก้อน action ผ่านการทำ destructuring นั่นเอง

TypeScript
1function handleAction(action: Action) {
2 switch (action.type) {
3 case 'CREATE_USER':
4 // create user
5 return;
6 case 'EDIT_USER':
7 // update user
8 console.log(action.payload.id);
9 return;
10 }
11}
TypeScript
1function handleAction(action: Action) {
2 const { type, payload } = action;
3
4 switch (type) {
5 case 'CREATE_USER':
6 // create user
7 return;
8 case 'EDIT_USER':
9 // update user
10 console.log(payload.id);
11 return;
12 }
13}

การมาของ TypeScript เวอร์ชั่น 4.6 ได้มีการปรับปรุงเรื่องของการทำ destructure ภายใต้ Discriminant Property ทำให้ตอนนี้เราสามารถ destructure ตัวแปรออกมาได้โดยไม่เกิดข้อผิดพลาดอีกต่อไป

การปรับปรุงการวิเคราะห์ส่วนของพารามิเตอร์ที่อิงค่าของกันและกัน

โดยทั่วไปแล้วฟังก์ชันสามารถรับพารามิเตอร์แบบ tuple ที่ใช้ช่องใดช่องหนึ่งเป็นตัวแบ่งประเภท (Discriminated Unions) เช่น เราอาจสร้างฟังก์ชันชื่อ handleUser ที่รับค่าเป็นพารามิเตอร์สองตำแหน่งด้วยรูปแบบการเรียกสองวิธี เช่น handleUser('Create', newUser) หรือ handleUser('Update', updatedUser) วิธีการเรียกฟังก์ชันนี้จะส่งพารามิเตอร์ตัวแรกคือ Create หรือ Update เพื่อใช้เป็นตัวกำกับว่าชนิดข้อมูลของพารามิเตอร์ตัวที่สองจะมีหน้าตาเป็นเช่นใด

TypeScript
1interface User {
2 id: number;
3 name: string;
4 email: string;
5}
6
7type HandleUserParams = ['Create', Omit<User, 'id'>] | ['Update', User];
8
9function handleUser(...args: HandleUserParams) {
10 switch (args[0]) {
11 case 'Create':
12 // create user
13 return;
14 case 'Update':
15 // update user
16 console.log(args[1].id);
17 return;
18 }
19}

ด้วยรูปแบบโค้ดข้างต้น เมื่อพารามิเตอร์ตัวแรกเป็น Create ข้อมูลของพารามิเตอร์ตัวหลังจะเป็น Omit<User, 'id'> ในขณะที่ User จะถูกใช้เป็นชนิดข้อมูลสำหรับกรณีที่พารามิเตอร์ตัวแรกเปลี่ยนเป็น Update

ด้วยรูปแบบการทำงานนี้ TypeScript สามารถอนุมานได้ว่าเมื่อ args[0] เป็น Update แสดงว่า args[1] ย่อมมีชนิดข้อมูลเป็น User นั่นแปลว่าต้องสามารถเรียกพร็อพเพอร์ตี้ id ได้ ทว่าเมื่อเราเปลี่ยนรูปแบบการประกาศชนิดข้อมูลใหม่ตามล่างนี้การอนุมานของ TypeScript จะผิดพลาดทันที

TypeScript
1interface User {
2 id: number;
3 name: string;
4 email: string;
5}
6
7type HandleUserParams = ['Create', Omit<User, 'id'>] | ['Update', User];
8
9type HandleUserFn = (...args: HandleUserParams) => void;
10
11const handleUser: HandleUserFn = (kind, user) => {
12 switch (kind) {
13 case 'Create':
14 // create user
15 return;
16 case 'Update':
17 // update user
18 console.log(user.id);
19 return;
20 }
21};

โค้ดข้างต้นแม้ TypeScript จะเข้าใจว่า kind สามารถเป็นได้ทั้ง Create และ Update แต่เมื่อทั้ง kind และ user ต่างแยกเป็นตัวแปรที่อิสระจาก args นั่นทำให้ TypeScript มองไม่เห็นความสัมพันธ์โดยตรงของมัน TypeScript จึงอนุมานให้ user มีชนิดข้อมูลเป็น User | Omit<User, "id"> โค้ดข้างต้นจึงคอมไพล์ไม่ผ่านเพราะไม่สามารถเรียก id จาก user ได้ในบรรทัดที่ 18

สำหรับ TypeScript 4.6 ได้ปรับปรุงการอนุมานในส่วนนี้ ตอนนี้เราสามารถทำงานกับโค้ดข้างต้นได้โดยไม่เกิดข้อผิดพลาดใด ๆ โดย TypeScript จะมอง user ในบรรทัดที่ 18 เป็นชนิดข้อมูล User ตรงตามความตั้งใจของนักพัฒนา

การปรับปรุงการวิเคราะห์การอนุมาน Index Access

สมมติเรามีชนิดข้อมูล Action ดังนี้

TypeScript
1interface User {
2 id: number;
3 name: string;
4 email: string;
5}
6
7type UpdateUser = Omit<User, 'id'>;
8
9type Action =
10 | { type: 'Create'; payload: UpdateUser; handler: (user: UpdateUser) => void }
11 | { type: 'Update'; payload: User; handler: (user: User) => void };

สำหรับตัวอย่างนี้ Action เป็นชนิดข้อมูลใช้ประกอบการตัดสินใจว่าจะดำเนินการกับ user อย่างไร ตัวอย่างเช่น หาก Action นั้นมี type เป็น Update นั่นแปลว่าเราต้องการอัปเดต user จึงต้องกำหนดค่า payload ให้มีชนิดข้อมูลเป็น User พร้อมทั้งส่วนจัดการคือ handler ให้เป็นฟังก์ชันที่รับพารามิเตอร์เป็นชนิดข้อมูล User

ลำดับถัดไปเราจะสร้างฟังก์ชันที่รับพารามิเตอร์เป็น action โดยฟังก์ชันนี้จะส่งค่า payload ของ action ไปยังส่วนของ handler ดังนี้

TypeScript
1function handleAction(action: Action) {
2 action.handler(action.payload);
3}

เหตุเพราะ payload ของ action เป็นได้ทั้งชนิดข้อมูล UpdateUser และ User บรรทัดที่ 2 TypeScript จึงมอง action.payload เป็นชนิดข้อมูล User | UpdateUser ในขณะที่ action.handler ถูกมองเป็น (user: UpdateUser) => void | (user: User) => void เมื่อชนิดข้อมูลเป็นไปได้สองทาง การกำหนดค่าของ action.payload ให้กับ action.handler โดยตรงจึงเป็นไปไม่ได้ ข้อผิดพลาดที่เกิดขึ้นจึงเป็นดังนี้

Code
1Argument of type 'User | UpdateUser' is not assignable to parameter of type 'User'.
2 Property 'id' is missing in type 'UpdateUser' but required in type 'User'.

TypeScript 4.6 ได้ปรับปรุงการอนุมานชนิดข้อมูลของ indexed access เมื่อใช้ควบคู่กับชนิดข้อมูล map ให้ดีขึ้น หากเราเปลี่ยนรูปแบบการประกาศชนิดข้อมูลข้างต้นเป็น Mapped types พร้อมใช้ Indexed access types ก็จะแก้ปัญหานี้ได้

เราจะเริ่มต้นจากการเปลี่ยนรูปแบบโค้ดตั้งต้นดังนี้

TypeScript
1interface User {
2 id: number;
3 name: string;
4 email: string;
5}
6
7type UpdateUser = Omit<User, 'id'>;
8
9type Action =
10 | { type: 'Create'; payload: UpdateUser; handler: (user: UpdateUser) => void }
11 | { type: 'Update'; payload: User; handler: (user: User) => void };

การเปลี่ยนแปลงนี้จะทำการสร้างชนิดข้อมูลใหม่สามตัว ได้แก่ ActionMap ActionType และ Action โดยผลของชนิดข้อมูล Action ในรูปแบบใหม่จะให้ผลลัพธ์เหมือนเดิม ดังนี้

TypeScript
1type ActionMap = { Create: UpdateUser; Update: User };
2type ActionType<K extends keyof ActionMap> = {
3 type: K;
4 payload: ActionMap[K];
5 handler: (user: ActionMap[K]) => void;
6};
7type Action = ActionType<'Create'> | ActionType<'Update'>;

จากรูปแบบการประกาศ Action เป็น ActionType<'Create'> | ActionType<'Update'> สามารถลดรูปด้วยการใช้ชนิดข้อมูล Map และ Indexed access ได้ดังนี้

TypeScript
1type ActionMap = { Create: UpdateUser; Update: User };
2type ActionType<K extends keyof ActionMap> = {
3 type: K;
4 payload: ActionMap[K];
5 handler: (user: ActionMap[K]) => void;
6};
7type Action<K extends keyof ActionMap = keyof ActionMap> = {
8 [P in keyof ActionMap]: ActionType<P>;
9}[K];

รูปแบบข้างต้นพบว่าแท้จริงแล้วเราไม่จำเป็นต้องประกาศชนิดข้อมูล ActionType ก็ได้ นั่นเพราะเราสามารถควบรวมพร็อพเพอร์ตี้ต่าง ๆ ของมันเข้าเป็นส่วนหนึ่งของ Action ได้โดยตรง ดังนี้

TypeScript
1type ActionMap = { Create: UpdateUser; Update: User };
2type Action<K extends keyof ActionMap = keyof ActionMap> = {
3 [P in K]: {
4 type: K;
5 payload: ActionMap[K];
6 handler: (user: ActionMap[K]) => void;
7 };
8}[K];

ผลลัพธ์สุดท้ายหลังการปรับเปลี่ยนโค้ดใหม่ทั้งหมดจะสามารถใช้ควบคู่กับฟังก์ชัน handleAction ได้ตามโค้ดล่างนี้

TypeScript
1interface User {
2 id: number;
3 name: string;
4 email: string;
5}
6
7type UpdateUser = Omit<User, 'id'>;
8
9type ActionMap = { Create: UpdateUser; Update: User };
10type Action<K extends keyof ActionMap = keyof ActionMap> = {
11 [P in K]: {
12 type: K;
13 payload: ActionMap[K];
14 handler: (user: ActionMap[K]) => void;
15 };
16}[K];
17
18function handleAction(action: Action) {
19 action.handler(action.payload);
20}

สำหรับ TypeScript เวอร์ชันก่อนหน้าคอมไพเลอร์ยังคงแจ้งเตือนข้อผิดพลาดของการเรียก action.handler ด้วยการส่ง action.payload อยู่ แต่สำหรับ TypeScript 4.6 นั้น คอมไพเลอร์จะมองเห็นความสัมพันธ์ของชนิด Action คือ Create และ Update ผ่านค่าคีย์คือ K เมื่อ K ถูกกำหนดเป็น Update ส่วนของ Mapped types จะผลิตผลลัพธ์เป็น { type: 'Update', payload: UpdateUser, handler: (user: UpdateUser) => void } ได้อย่างถูกต้อง ปัญหาทั้งหมดจึงหมดไปนั่นเอง

การอนุญาตให้มีส่วนของโค้ดก่อนเรียก super ใน constructors

ภาษา JavaScript และ TypeScript ไม่อนุญาตให้มีการเข้าถึง this ก่อนการเรียก super ใน constructors โค้ดต่อไปนี้มีการเข้าถึง this.#url ก่อนการเรียก super ข้อผิดพลาดจึงเกิดขึ้น

TypeScript
1class Client {}
2
3class HttpClient extends Client {
4 #url: string;
5
6 constructor(url: string) {
7 // 'super' must be called before accessing 'this' in the constructor of a derived class.
8 this.#url = url;
9 super();
10 }
11}

อย่างไรก็ตาม TypeScript เวอร์ชันก่อนหน้านี้ไม่เพียงแต่โยนข้อผิดพลาดเมื่อเรียกใช้ this ก่อน super แต่ยังโยนข้อผิดพลาด เมื่อเรียกโค้ดใด ๆ ก่อน super ด้วย เช่น

TypeScript
1function validateUrl(url: string) {
2 // ...
3}
4
5class Client {}
6
7class HttpClient extends Client {
8 #url: string;
9
10 constructor(url: string) {
11 // A 'super' call must be the first statement in the constructor
12 // when a class contains initialized properties,
13 // parameter properties, or private identifiers.
14 validateUrl(url);
15 super();
16 this.#url = url;
17 }
18}

ไม่ใช่สำหรับ TypeScript 4.6 ข้อผิดพลาดนี้จะไม่เกิดขึ้น ตอนนี้เราสามารถเรียกโค้ดใด ๆ ก่อนหน้า super ได้แล้ว เพียงแต่โค้ดนั้นต้องไม่มีการเข้าถึง this ก่อนการเรียก super นั่นเอง

การปรับปรุงการตรวจสอบลำดับชั้นของการเรียกซ้ำ (Recursion Depth Checks)

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

TypeScript
1interface Foo<T> {
2 bar: T;
3}
4
5declare let x: Foo<Foo<Foo<Foo<Foo<Foo<string>>>>>>;
6declare let y: Foo<Foo<Foo<Foo<Foo<string>>>>>;
7
8x = y;

สำหรับ TypeScript เมื่อทำ Recursion บนชนิดข้อมูลมากระดับหนึ่งจนคล้ายว่าจะเป็นการวนซ้ำไม่รู้จบ คอมไพเลอร์จะปล่อยผ่านเสมือนค่าทั้งสองสามารถเทียบกันได้

อย่างไรก็ตามเมื่อใช้งานควบคู่กับ TypeScript 4.6 คอมไพเลอร์จะตรวจสอบพบว่า x และ y ต่างไม่เท่ากับ ทั้งนี้ฟีเจอร์นี้ได้ถูกนำไปใช้กับ TypeScript 4.5.3 ด้วยเป็นผลให้ตั้งแต่เวอร์ชัน 4.5.3 การตรวจสอบเช่นนี้จึงเป็นผลด้วยเช่นกัน

--target es2022

ออพชั่น --target ของ TypeScript ตอนนี้สนับสนุนการระบุ es2022 เพื่อเข้าถึงฟีเจอร์ของ ES2022 เช่น เมธอด at ของอาร์เรย์ เป็นต้น

TypeScript
1const arr = ['A', 'B', 'C'];
2
3arr.at(1); // B

TypeScript เวอร์ชัน 4.6 ยังมีการปรับปรุงส่วนอื่น ๆ ที่ไม่ได้กล่าวถึงในบทความนี้ ผู้อ่านสามารถเข้าไปอ่านรายละเอียดเต็มได้จาก Announcing TypeScript 4.6

เรียนรู้ TypeScript อย่างมืออาชีพ

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

เอกสารอ้างอิง

Announcing TypeScript 4.6. Retrieved March, 5, 2022, from https://devblogs.microsoft.com/typescript/announcing-typescript-4-6/

Fix multiple issues with indexed access types applied to mapped types. Retrieved March, 5, 2022, from https://github.com/microsoft/TypeScript/pull/47109

สารบัญ

สารบัญ

  • การปรับปรุงเพื่อวิเคราะห์การทำ destructure ภายใต้ discriminated unions
  • การปรับปรุงการวิเคราะห์ส่วนของพารามิเตอร์ที่อิงค่าของกันและกัน
  • การปรับปรุงการวิเคราะห์การอนุมาน Index Access
  • การอนุญาตให้มีส่วนของโค้ดก่อนเรียก super ใน constructors
  • การปรับปรุงการตรวจสอบลำดับชั้นของการเรียกซ้ำ (Recursion Depth Checks)
  • --target es2022
  • เรียนรู้ TypeScript อย่างมืออาชีพ
  • เอกสารอ้างอิง