มีอะไรใหม่บ้างใน TypeScript 5.0
TypeScript ภาษาสุดฮิตของยอดมนุษย์ JavaScript ผู้รักใน Type เป็นชีวิตจิตใจ ในที่สุดก็ได้เดินทางมาถึงเวอร์ชัน 5.0 แล้ว จะมีของเด็ดอะไรปล่อยออกมาบ้างนั้นไปดูกันเลย
const Type Parameters
ก่อนที่จะเข้าใจการทำงานของ const Type Parameters เรามาดูสถานการณ์สมมตินี้กันก่อนครับ
กำหนดให้ชนิดข้อมูล Palette ใช้เพื่อเก็บค่าสีสองจำพวกคือ สีประเภท primary และ accent
1type Palette = {2 accent: string[];3 primary: string[];4};
โดยเราจะสร้างฟังก์ชันชื่อ getPrimaryColors
ขึ้นมาเพื่อคืนค่าสีในกลุ่ม primary เจ้าฟังก์ชันนี้หน้าตาก็จะเป็นประมาณนี้ครับ
1function getPrimaryColors(palette: Palette) {2 return palette.primary;3}
ถ้าเราลองเรียกใช้ฟังก์ชันดังกล่าวพร้อมส่งค่าสีเข้าไปจากนั้นจึงคืนกลับค่าของฟังก์ชันกลับมาที่ตัวแปร primaryColors
เราจะพบว่า primaryColors จะมีชนิดข้อมูลเป็น string[]
1// ชนิดข้อมูลคือ string[]2const primaryColors = getPrimaryColors({3 accent: ['#f1c40f', '#e67e22', '#e74c3c'],4 primary: ['#16a085', '#2980b9', '#2c3e50'],5});
แต่เพราะเราทราบอยู่แล้วว่าค่าสีเหล่านี้ต้องเป็นค่าคงที่ เมื่อเป็นเช่นนี้ตัวแปร primaryColors
ของเราก็ควรมีชนิดข้อมูลเป็น
readonly ["#16a085", "#2980b9", "#2c3e50"]
ถึงจะเหมาะสมกว่า
เพื่อให้ความปรารถนาของเรานั้นเป็นจริง เราต้องเริ่มจากการทำให้ชนิดข้อมูล Palette นั้นอยู่ในรูปแบบที่อ่านค่าได้อย่างเดียว (แก้ไขค่าไม่ได้) ด้วยการเติม readonly
1type Palette = {2 readonly accent: readonly string[];3 readonly primary: readonly string[];4};56// หรืออีกวิธีหนึ่งคือ7type DeepReadonly<T> = {8 readonly [P in keyof T]: DeepReadonly<T[P]>;9};1011type Palette = DeepReadonly<{12 accent: string[];13 primary: string[];14}>;
ถึงตอนนี้ Palette จะเข้าใจว่า primary นั้นเป็นอาร์เรย์ของ string เพื่อให้พารามิเตอร์ของฟังก์ชันรู้ว่าค่าของมันนั้นสามารถแปรเปลี่ยนไปตามชนิดข้อมูลของส่วนอาร์กิวเมนต์ได้ เราจึงใช้ Generic Type Parameters เข้าช่วย ดังนี้
1function getPrimaryColors<T extends Palette>(palette: T) {2 return palette.primary;3}
อย่างไรก็ตามค่าข้อมูลที่เราส่งเข้ามาในชื่อ palette ของฟังก์ชัน TypeScript ยังคงมองส่วนของ primary ว่าเป็น string[]
อยู่
เนื่องจากค่าที่ส่งเข้ามาไม่ได้ระบุว่าเป็นค่าคงที่ เราจึงต้องใช้ Const Assertion ผ่านการใส่ as const
ต่อท้ายเพื่อให้ TypeScript เข้าใจว่าตัวมันเองเป็นค่าคงที่
1const primaryColors = getPrimaryColors({2 accent: ['#f1c40f', '#e67e22', '#e74c3c'],3 primary: ['#16a085', '#2980b9', '#2c3e50'],4} as const);
ถึงตอนนี้ชนิดข้อมูลของตัวแปร primaryColors
จะมีชนิดข้อมูลเป็น readonly string[]
สาเหตุที่ได้ชนิดข้อมูลออกมาในรูปนี้
นั่นเพราะว่าเราคืนค่าจากฟังก์ชันด้วย palette.primary
เมื่อ palette.primary
มีชนิดข้อมูลเป็น readonly string[]
ค่าของ primaryColors
จึงได้ชนิดข้อมูลนี้ตามด้วย
เราจะทำการใส่ Return Type ให้กับฟังก์ชัน เพื่อเจาะจงว่าชนิดข้อมูลผลลัพธ์ต้องพิจารณาจากค่าของ palette ที่แท้จริง ดังนี้
1function getPrimaryColors<T extends Palette>(palette: T) {2 return palette.primary;3}
เมื่อกระบวนการข้างต้นเสร็จสิ้น ตอนนี้เราจะได้ชนิดข้อมูลของ primaryColors
เป็น readonly ["#16a085", "#2980b9", "#2c3e50"]
แล้วละ
และนี่คือโค้ดทั้งหมดที่เราได้เขียนขึ้นมา
1type DeepReadonly<T> = {2 readonly [P in keyof T]: DeepReadonly<T[P]>;3};45type Palette = DeepReadonly<{6 accent: string[];7 primary: string[];8}>;910function getPrimaryColors<T extends Palette>(palette: T): T['primary'] {11 return palette.primary;12}1314const primaryColors = getPrimaryColors({15 accent: ['#f1c40f', '#e67e22', '#e74c3c'],16 primary: ['#16a085', '#2980b9', '#2c3e50'],17} as const);
สิ่งหนึ่งที่ทำให้ความสามารถนี้เกิดขึ้นได้คือการระบุ Const Assertion หรือ as const
ต่อท้ายออบเจ็กต์เข้าไป
แน่นอนว่าเราใส่ as const
ขณะที่เรียกใช้ฟังก์ชัน เมื่อเป็นส่วนของการเรียกใช้ API ก็เป็นไปได้ที่นักพัฒนามักจะลืมใส่ as const
ต่อท้าย
ทางแก้ของเราจึงอยู่ที่ TypeScript 5.0 กับฟีเจอร์ที่ชื่อว่า Const Type Parameters
Const Type Parameters คือการระบุ const modifier ในส่วนของ Generic Type Parameters
เพื่อให้ตัวภาษาอนุมานว่า Type Parameters ของเราเป็นค่าคงที่นั่นเอง นั่นทำให้จังหวะของการเรียกใช้งานฟังก์ชัน
เราไม่ต้องต่อท้ายออบเจ็กต์ด้วย as const
อีกต่อไป
1function getPrimaryColors<const T extends Palette>(palette: T): T["primary"] {2 return palette.primary;3}45// ชนิดข้อมูลเป็น readonly ["#16a085", "#2980b9", "#2c3e50"]6const primaryColors = getPrimaryColors({7 accent: ['#f1c40f', '#e67e22', '#e74c3c'],8 primary: ['#16a085', '#2980b9', '#2c3e50']9});
การใช้ const modifier นี้จะไม่ส่งผลต่อข้อมูลที่เปลี่ยนแปลงค่าได้ (mutable values)
เช่น ถ้าชนิดข้อมูล Palette ของเราไม่ระบุ readonly เป็นผลให้ข้อมูลสามารถถูกแก้ไขได้
การใส่ const จะไม่สามารถอนุมานให้ primaryColors มีชนิดข้อมูลเป็น readonly ["#16a085", "#2980b9", "#2c3e50"]
ได้อีกต่อไป นั่นเพราะส่วนของ primary ในชนิดข้อมูล Palette สามารถเปลี่ยนแปลงเป็นค่าอื่นได้นั่นเอง
1type Palette = {2 accent: string[];3 primary: string[];4};56function getPrimaryColors<const T extends Palette>(palette: T): T["primary"] {7 return palette.primary;8}910// ชนิดข้อมูลเป็น string[]11const primaryColors = getPrimaryColors({12 accent: ['#f1c40f', '#e67e22', '#e74c3c'],13 primary: ['#16a085', '#2980b9', '#2c3e50']14});
สิ่งหนึ่งที่ต้องทราบเกี่ยวกับการใช้ const คือมันจะมีผลเฉพาะการส่งผ่านค่าไปยังฟังก์ชันโดยตรง การประกาศตัวแปรแยกไว้ข้างนอกแล้วค่อยนำส่งไปยังฟังก์ชันจะไม่ได้ผลลัพธ์ตามที่ต้องการ
1type Palette = DeepReadonly<{2 accent: string[];3 primary: string[];4}>;56function getPrimaryColors<const T extends Palette>(palette: T): T["primary"] {7 return palette.primary;8}910const colors = {11 accent: ['#f1c40f', '#e67e22', '#e74c3c'],12 primary: ['#16a085', '#2980b9', '#2c3e50']13}1415// ชนิดข้อมูลเป็น string[]16const primaryColors = getPrimaryColors(colors);
Enum Unification
ภาษา TypeScript มี Enum สองรูปแบบคือ Numeric Enum Types และ Literal Enum Types (หรือที่รู้จักกันในชื่อ Union Enum Types)
สำหรับ Numeric Enum Types ตัวเลขหรือสมาชิกใด ๆ ของ Enum นั้น ๆ จะสามารถเข้าคู่กับชนิดข้อมูล Enum ตัวมันเองได้
1enum Num {2 One,3 Two,4 Three,5}67function num(n: Num) {}89num(Num.One); // เรียกได้10num(123); // เรียกได้ แต่จะ error ใน TypeScript 5.0
ในกรณีของ Union Enum Types ชนิดข้อมูลแต่ละตัวจะถือว่าเป็นชนิดข้อมูลใหม่ที่ต้องกำหนดค่าให้เป็นชนิดข้อมูลที่เป็นค่าคงที่ของ number หรือ string เท่านั้น หากมีสมาชิกตัวใดตัวหนึ่งที่ไม่ใช่ค่าคงที่เราจะไม่สามารถนำสมาชิกแต่ละตัวของ Enum นั้นมากำหนดเป็นชนิดข้อมูลใหม่ได้
1enum HttpStatus {2 Ok = 200,3 NotFound = 404,4 Random = Math.random(),5}67// error: Enum type 'HttpStatus' has members with initializers that are not literals.8type StandardStatuses = HttpStatus.Ok | HttpStatus.NotFound | HttpStatus.Random;910// error: Enum type 'HttpStatus' has members with initializers that are not literals.11type UnknownStatuses = HttpStatus.Random;
สิ่งที่ TypeScript เวอร์ชันเข้ามาปรับปรุงในส่วนนี้คือการทำให้ Enum ทั้งหมดนั้นเป็น Unoin Enums ที่สามารถมีสมาชิกเป็นได้ทั้งค่าคงที่ (Literal Enum Members) หรือเป็นสมาชิกที่ต้องอาศัยการคำนวณค่า (Opaque Computed Enum Member)
1enum HttpStatus {2 Ok = 200, // Numeric literal enum member3 NotFound = '404', // String literal enum member4 Random = Math.random(), // Opaque computed enum member5}
สำหรับสมาชิกประเภท Opaque Computed Enum Members นั้นจะต้องเป็นการคำนวณที่คืนค่าเป็นชนิดข้อมูล number เท่านั้น นั่นทำให้ทุก ๆ ตัวเลขจะสามารถกำหนดค่าให้สมาชิกประเภทนี้ได้เสมอ
1enum HttpStatus {2 Ok = 200,3 NotFound = 404,4 Random = Math.random(),5}67const s: UnknownStatuses = 123;
Literal Enum Members ไม่จำเป็นที่จะต้องกำหนดเป็นค่าคงที่เดี่ยว ๆ เท่านั้น หากแต่สามารถอาศัยการคำนวณค่าคงที่อื่น ๆ เข้าด้วยกันได้ เช่น
1const BASE_URL = '/api/v1';23const enum Routes {4 Products = `${BASE_URL}/products`, // "/api/v1/products"5 Orders = `${BASE_URL}/orders`, // "/api/v1/orders"6}
สิ่งหนึ่งที่ต้องทราบเกี่ยวกับ Opaque Computed Enum Members คือ สมาชิกประเภทนี้จะไม่สามารถใช้คู่กับ const enum ได้ครับ
Decorators รูปแบบใหม่
ก่อนหน้านี้ TypeScript ได้สนับสนุนการใช้งาน Decorators จากสเปคเวอร์ชันเก่าผ่านค่าคอนฟิคชื่อ experimentalDecorators
ไปแล้ว มีหลายโปรเจคที่ใช้งานฟีเจอร์ Decorators เวอร์ชันเก่านี้อยู่เช่น Nest.js เป็นต้น
ปัจจุบันเรามีสเปคใหม่ของฟีเจอร์ Decorators อยู่ในสถานะ Stage 3 แปลว่ามาตรฐานนี้ใกล้คลอดเพื่อเป็นอีกหนึ่งฟีเจอร์ใหม่ของ JavaScript แล้ว ด้วยเหตุนี้ TypeScript 5.0 จึงได้บรรจุ Decorators ตามมาตรฐานใหม่เข้ามาในเวอร์ชันนี้ด้วย
เว็บเราจะมีบทความสอนใช้งาน Decorators แบบลงลึกแยกอีกที ดังนั้นหัวข้อนี้เราจะทำความรู้จัก Decorators แบบคร่าว ๆ กันก่อนครับ
สมมติเรามีคลาสสำหรับบัญชีออมทรัพย์ที่สามารถเรียก balance
เพื่อคืนค่าเงินสะสมและ interest
สำหรับคำนวณดอกเบี้ยเงินฝาก ดังนี้
1class SavingAccount {2 static INTEREST_RATE = 0.0125;34 #balance: number;56 constructor(balance: number) {7 this.#balance = balance;8 }910 get balance() {11 return this.#balance;12 }1314 get interest() {15 return this.#balance * SavingAccount.INTEREST_RATE;16 }17}
ด้วยโค้ดข้างต้นเราสามารถพิมพ์ค่าเงินคงเหลือและดอกเบี้ยจากการคำนวณได้ดังนี้
1const account = new SavingAccount(100_000);2console.log(account.balance); // 1000003console.log(account.interest); // 1250
สิ่งหนึ่งที่เราเห็นแล้วรู้สึกขัดตาคือผลลัพธ์ของตัวเลขแม้เลยจะยาวเพียงใดก็ไม่มีลูกน้ำคั่นซะเลย
เราจึงจะทำการแก้ไขโค้ดด้วยการเติม $
เข้าไปนำหน้าจำนวนเงินและทำให้ตัวเลขของเรามีการคั่นลูกน้ำอย่างสวยงาม
1class SavingAccount {2 static INTEREST_RATE = 0.0125;34 #balance: number;56 constructor(balance: number) {7 this.#balance = balance;8 }910 get balance() {11 return this.#balance.toLocaleString('en-US', {12 style: 'currency',13 currency: 'USD',14 });15 }1617 get interest() {18 return (this.#balance * SavingAccount.INTEREST_RATE).toLocaleString(19 'en-US',20 {21 style: 'currency',22 currency: 'USD',23 }24 );25 }26}2728const account = new SavingAccount(100_000);29console.log(account.balance); // "$100,000.00"30console.log(account.interest); // "$1,250.00"
ผลลัพธ์จากโค้ดชุดใหม่เป็นอะไรที่ตรงใจมาก ทว่าการเพิ่มโค้ดด้วยการใช้ toLocaleString
เพื่อเติมลูกน้ำและดอลล่าร์
ดูจะยุ่งยากเกินไปเมื่อต้องใช้โค้ดชุดนี้กับทุกตำแหน่งที่ต้องการแปลงตัวเลข อย่ากระนั้นเลยเรามาลองใช้ Decorators เพื่อลดความซับซ้อนของโค้ดกันดีกว่า!
1class SavingAccount {2 static INTEREST_RATE = 0.0125;34 #balance: number;56 constructor(balance: number) {7 this.#balance = balance;8 }910 @currency11 get balance() {12 return this.#balance;13 }1415 @currency16 get interest() {17 return this.#balance * SavingAccount.INTEREST_RATE;18 }19}
ส่วนของ @currency
ที่เราเห็นเป็นฟังก์ชันที่เราต้องสร้างขึ้นมา ฟังก์ชันประเภทนี้มีหน้าที่ตกแต่งให้ฟังก์ชันของเดิมคือ get balance()
และ get interest()
มีความสามารถมากขึ้นคือแปลงตัวเลขธรรมดาให้อยู่ในรูปแบบค่าเงิน ฟังก์ชันไม่ธรรมดาที่ขยายความสามารถได้นี้จึงเรียกว่า Decorators
และนี่คือหน้าตาฟังก์ชัน currency
ของเราที่เมื่อใส่ไปแล้วจะทำให้การเรียกใช้ balance
และ interest
ได้ผลลัพธ์เช่นเดิมโดยไม่ต้องไปแก้ไขด้วยการเติม toLocaleString
ในโค้ดต้นฉบับเลยครับ
1function currency(klass: any, context: ClassGetterDecoratorContext) {2 return function (this: any, ...args: any[]) {3 return klass.call(this, ...args).toLocaleString('en-US', {4 style: 'currency',5 currency: 'USD',6 });7 };8}
สำหรับคำอธิบายถึงการสร้างและใช้งาน Decorators นั้นเราจะแยกเขียนเพื่ออธิบายอย่างลึกซึ้งในอีกบทความนึง
สำหรับ Decorators รูปแบบใหม่นี้สามารถใช้ได้ทันทีในเวอร์ชัน 5.0 ครับ แต่ถ้าโปรเจคของเรามีการตั้งค่า experimentalDecorators
ไว้
TypeScript ก็จะยังคงใช้ Decorators ตามสเปคเก่าอยู่
--verbatimModuleSyntax
ภายใต้ประโยค import ภาษา TypeScript สามารถตัดสินใจได้ว่าสิ่งใดควรถูกกำจัดทิ้งเมื่อแปลงผลลัพธ์เป็น JavaScript
1import { Circle } from './circle';23export function dupCircle(circle: Circle) {}
โค้ดข้างต้น TypeScript จะมองว่า Circle เป็นเพียงชนิดข้อมูลไม่จำเป็นต่อการใช้งานใน JavaScript เมื่อทำการ Build ผลลัพธ์ตัวภาษาจึงกำจัดประโยค import ทิ้งเพราะไม่จำเป็นต่อการใช้งาน เราจึงได้โค้ดใหม่ใน JavaScript ดังนี้
1export function dupCircle(circle) {}
เราเรียกพฤติกรรมการตัดประโยค import ในลักษณะนี้ว่า Import Elison
แม้ว่า Import Elison จะเป็นความสามารถที่ดี แต่ถ้าไฟล์ต้นทางของเรามี Side Effect คือกระทำงานบางอย่างเมื่อเรา import เช่นโค้ดต่อไปนี้เมื่อทำการ import ไฟล์จำทำการ setup ฐานข้อมูลโดยอัตโนมัติ
1import { Db } from './db';23export function connect(db: DB) {}45// ไฟล์ db.ts67export type Db = {};89function setupDb() {}1011setupDb();
จากโค้ดข้างต้นถ้า TypeScript กระทำ Import Elison นั่นจะทำให้ขั้นตอนการ setup ฐานข้อมูลหายไปทันที
สำหรับ TypeScript 5.0 มีออปชั่นใหม่คือ --verbatimModuleSyntax
ช่วยแก้ปัญหาความสับสนในจุดนี้
เมื่อใช้ออปชั่นดังกล่าว หากส่วนใดที่เป็นประโยค import แบบ type ส่วนนั้นจะถูกลบออกไป แต่ส่วนอื่นที่ไม่ใช่การ import type จะยังคงอยู่
1// ลบทิ้งทั้งหมดเพราะไม่ได้ใช้เลย2import type { Circle } from "./circle";34// ผลลัพธ์ใน JavaScript เป็น 'import { PI } from "./circle";'5import { PI, type Circle } from "./circle";67// ผลลัพธ์ใน JavaScript เป็น 'import {} from "./circle";'8import { type Circle } from "./circle";
export type *
TypeScript 5.0 ตอนนี้เราสามารถใช้ประโยค export type *
เพื่อทำการ export เฉพาะชนิดข้อมูลได้แล้ว
1// models/shapes.ts2export class Circle {}3export type CircleColor = 'red' | 'green' | 'blue';45// models/index.ts6export type * as shapes from './shapes';78// main.ts9import { shapes } from './models';1011// shapes.Circle จะถูกใช้ในฐานะของชนิดข้อมูล12function cloneCircle(circle: shapes.Circle) {}1314function buildCircle() {15 // ไม่สามารถเรียกใช้งานได้เนื่องจาก Circle ถูก export มาเป็นชนิดข้อมูลเท่านั้น16 return new shapes.Circle();17}
การอนุญาตให้มีหลายไฟล์คอนฟิคในส่วนของ extends
TypeScript 5.0 อนุญาตให้เราสามารถ extends หลายไฟล์คอนฟิคได้แล้ว
1// tsconfig.json2{3 "extends": ["a", "b", "c"],4 "compilerOptions": {5 // ...6 }7}
โดยการ extends นั้นหากมีค่าคอนฟิคใดที่ตรงกันในแต่ละไฟล์ ค่าคอนฟิคของไฟล์ล่าสุดในรายการ extends จะเป็นค่าสุดท้ายที่ได้รับเลือก
1// tsconfig.base.json2{3 "compilerOptions": {4 "strictNullChecks": true,5 "strict": true6 }7}89// tsconfig.team.json10{11 "compilerOptions": {12 "noImplicitAny": true,13 "strict": false14 }15}1617// tsconfig.json18{19 "extends": ["./tsconfig.base.json", "./tsconfig.team.json"],20 "files": ["./index.ts"]21}
จากโค้ดข้างต้นผลลัพธ์สุดท้ายจะได้ทั้งค่า strictNullChecks
, noImplicitAny
แต่สำหรับค่า strict
นั้นจะมีค่าเป็น false
เรียนรู้ TypeScript อย่างมืออาชีพ
คอร์สออนไลน์ Comprehensive TypeScript คอร์สสอนการใช้งาน TypeScript ตั้งแต่เริ่มต้นจนถึงขั้นสูง เรียนรู้หลักการทำงานของ TypeScript การประกาศชนิดข้อมูลต่าง ๆ พร้อมการใช้งานขั้นสูงพร้อมรองรับการทำงานกับ TypeScript เวอร์ชัน 5.0 ด้วย
สรุป
TypeScript 5.0 ยังมีฟีเจอร์อื่นอีกมากที่ไม่ได้กล่าวถึงในบทความนี้ หากคุณผู้อ่านสนใจสามารถศึกษาเพิ่มเติมได้จาก Announcing TypeScript 5.0
สารบัญ
- const Type Parameters
- Enum Unification
- Decorators รูปแบบใหม่
- --verbatimModuleSyntax
- export type *
- การอนุญาตให้มีหลายไฟล์คอนฟิคในส่วนของ extends
- เรียนรู้ TypeScript อย่างมืออาชีพ
- สรุป