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

Nuttavut Thongjor

TypeScript ภาษาสุดฮิตของยอดมนุษย์ JavaScript ผู้รักใน Type เป็นชีวิตจิตใจ ในที่สุดก็ได้เดินทางมาถึงเวอร์ชัน 5.0 แล้ว จะมีของเด็ดอะไรปล่อยออกมาบ้างนั้นไปดูกันเลย

const Type Parameters

ก่อนที่จะเข้าใจการทำงานของ const Type Parameters เรามาดูสถานการณ์สมมตินี้กันก่อนครับ

กำหนดให้ชนิดข้อมูล Palette ใช้เพื่อเก็บค่าสีสองจำพวกคือ สีประเภท primary และ accent

TypeScript
1type Palette = {
2 accent: string[];
3 primary: string[];
4};

โดยเราจะสร้างฟังก์ชันชื่อ getPrimaryColors ขึ้นมาเพื่อคืนค่าสีในกลุ่ม primary เจ้าฟังก์ชันนี้หน้าตาก็จะเป็นประมาณนี้ครับ

TypeScript
1function getPrimaryColors(palette: Palette) {
2 return palette.primary;
3}

ถ้าเราลองเรียกใช้ฟังก์ชันดังกล่าวพร้อมส่งค่าสีเข้าไปจากนั้นจึงคืนกลับค่าของฟังก์ชันกลับมาที่ตัวแปร primaryColors เราจะพบว่า primaryColors จะมีชนิดข้อมูลเป็น string[]

TypeScript
1// ชนิดข้อมูลคือ string[]
2const primaryColors = getPrimaryColors({
3 accent: ['#f1c40f', '#e67e22', '#e74c3c'],
4 primary: ['#16a085', '#2980b9', '#2c3e50'],
5});

แต่เพราะเราทราบอยู่แล้วว่าค่าสีเหล่านี้ต้องเป็นค่าคงที่ เมื่อเป็นเช่นนี้ตัวแปร primaryColors ของเราก็ควรมีชนิดข้อมูลเป็น readonly ["#16a085", "#2980b9", "#2c3e50"] ถึงจะเหมาะสมกว่า

เพื่อให้ความปรารถนาของเรานั้นเป็นจริง เราต้องเริ่มจากการทำให้ชนิดข้อมูล Palette นั้นอยู่ในรูปแบบที่อ่านค่าได้อย่างเดียว (แก้ไขค่าไม่ได้) ด้วยการเติม readonly

TypeScript
1type Palette = {
2 readonly accent: readonly string[];
3 readonly primary: readonly string[];
4};
5
6// หรืออีกวิธีหนึ่งคือ
7type DeepReadonly<T> = {
8 readonly [P in keyof T]: DeepReadonly<T[P]>;
9};
10
11type Palette = DeepReadonly<{
12 accent: string[];
13 primary: string[];
14}>;

ถึงตอนนี้ Palette จะเข้าใจว่า primary นั้นเป็นอาร์เรย์ของ string เพื่อให้พารามิเตอร์ของฟังก์ชันรู้ว่าค่าของมันนั้นสามารถแปรเปลี่ยนไปตามชนิดข้อมูลของส่วนอาร์กิวเมนต์ได้ เราจึงใช้ Generic Type Parameters เข้าช่วย ดังนี้

TypeScript
1function getPrimaryColors<T extends Palette>(palette: T) {
2 return palette.primary;
3}

อย่างไรก็ตามค่าข้อมูลที่เราส่งเข้ามาในชื่อ palette ของฟังก์ชัน TypeScript ยังคงมองส่วนของ primary ว่าเป็น string[] อยู่ เนื่องจากค่าที่ส่งเข้ามาไม่ได้ระบุว่าเป็นค่าคงที่ เราจึงต้องใช้ Const Assertion ผ่านการใส่ as const ต่อท้ายเพื่อให้ TypeScript เข้าใจว่าตัวมันเองเป็นค่าคงที่

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 ที่แท้จริง ดังนี้

TypeScript
1function getPrimaryColors<T extends Palette>(palette: T) {
2 return palette.primary;
3}

เมื่อกระบวนการข้างต้นเสร็จสิ้น ตอนนี้เราจะได้ชนิดข้อมูลของ primaryColors เป็น readonly ["#16a085", "#2980b9", "#2c3e50"] แล้วละ และนี่คือโค้ดทั้งหมดที่เราได้เขียนขึ้นมา

TypeScript
1type DeepReadonly<T> = {
2 readonly [P in keyof T]: DeepReadonly<T[P]>;
3};
4
5type Palette = DeepReadonly<{
6 accent: string[];
7 primary: string[];
8}>;
9
10function getPrimaryColors<T extends Palette>(palette: T): T['primary'] {
11 return palette.primary;
12}
13
14const 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 อีกต่อไป

TypeScript
1function getPrimaryColors<const T extends Palette>(palette: T): T["primary"] {
2 return palette.primary;
3}
4
5// ชนิดข้อมูลเป็น 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 สามารถเปลี่ยนแปลงเป็นค่าอื่นได้นั่นเอง

TypeScript
1type Palette = {
2 accent: string[];
3 primary: string[];
4};
5
6function getPrimaryColors<const T extends Palette>(palette: T): T["primary"] {
7 return palette.primary;
8}
9
10// ชนิดข้อมูลเป็น string[]
11const primaryColors = getPrimaryColors({
12 accent: ['#f1c40f', '#e67e22', '#e74c3c'],
13 primary: ['#16a085', '#2980b9', '#2c3e50']
14});

สิ่งหนึ่งที่ต้องทราบเกี่ยวกับการใช้ const คือมันจะมีผลเฉพาะการส่งผ่านค่าไปยังฟังก์ชันโดยตรง การประกาศตัวแปรแยกไว้ข้างนอกแล้วค่อยนำส่งไปยังฟังก์ชันจะไม่ได้ผลลัพธ์ตามที่ต้องการ

TypeScript
1type Palette = DeepReadonly<{
2 accent: string[];
3 primary: string[];
4}>;
5
6function getPrimaryColors<const T extends Palette>(palette: T): T["primary"] {
7 return palette.primary;
8}
9
10const colors = {
11 accent: ['#f1c40f', '#e67e22', '#e74c3c'],
12 primary: ['#16a085', '#2980b9', '#2c3e50']
13}
14
15// ชนิดข้อมูลเป็น string[]
16const primaryColors = getPrimaryColors(colors);

Enum Unification

ภาษา TypeScript มี Enum สองรูปแบบคือ Numeric Enum Types และ Literal Enum Types (หรือที่รู้จักกันในชื่อ Union Enum Types)

สำหรับ Numeric Enum Types ตัวเลขหรือสมาชิกใด ๆ ของ Enum นั้น ๆ จะสามารถเข้าคู่กับชนิดข้อมูล Enum ตัวมันเองได้

TypeScript
1enum Num {
2 One,
3 Two,
4 Three,
5}
6
7function num(n: Num) {}
8
9num(Num.One); // เรียกได้
10num(123); // เรียกได้ แต่จะ error ใน TypeScript 5.0

ในกรณีของ Union Enum Types ชนิดข้อมูลแต่ละตัวจะถือว่าเป็นชนิดข้อมูลใหม่ที่ต้องกำหนดค่าให้เป็นชนิดข้อมูลที่เป็นค่าคงที่ของ number หรือ string เท่านั้น หากมีสมาชิกตัวใดตัวหนึ่งที่ไม่ใช่ค่าคงที่เราจะไม่สามารถนำสมาชิกแต่ละตัวของ Enum นั้นมากำหนดเป็นชนิดข้อมูลใหม่ได้

TypeScript
1enum HttpStatus {
2 Ok = 200,
3 NotFound = 404,
4 Random = Math.random(),
5}
6
7// error: Enum type 'HttpStatus' has members with initializers that are not literals.
8type StandardStatuses = HttpStatus.Ok | HttpStatus.NotFound | HttpStatus.Random;
9
10// 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)

TypeScript
1enum HttpStatus {
2 Ok = 200, // Numeric literal enum member
3 NotFound = '404', // String literal enum member
4 Random = Math.random(), // Opaque computed enum member
5}

สำหรับสมาชิกประเภท Opaque Computed Enum Members นั้นจะต้องเป็นการคำนวณที่คืนค่าเป็นชนิดข้อมูล number เท่านั้น นั่นทำให้ทุก ๆ ตัวเลขจะสามารถกำหนดค่าให้สมาชิกประเภทนี้ได้เสมอ

TypeScript
1enum HttpStatus {
2 Ok = 200,
3 NotFound = 404,
4 Random = Math.random(),
5}
6
7const s: UnknownStatuses = 123;

Literal Enum Members ไม่จำเป็นที่จะต้องกำหนดเป็นค่าคงที่เดี่ยว ๆ เท่านั้น หากแต่สามารถอาศัยการคำนวณค่าคงที่อื่น ๆ เข้าด้วยกันได้ เช่น

TypeScript
1const BASE_URL = '/api/v1';
2
3const 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 สำหรับคำนวณดอกเบี้ยเงินฝาก ดังนี้

TypeScript
1class SavingAccount {
2 static INTEREST_RATE = 0.0125;
3
4 #balance: number;
5
6 constructor(balance: number) {
7 this.#balance = balance;
8 }
9
10 get balance() {
11 return this.#balance;
12 }
13
14 get interest() {
15 return this.#balance * SavingAccount.INTEREST_RATE;
16 }
17}

ด้วยโค้ดข้างต้นเราสามารถพิมพ์ค่าเงินคงเหลือและดอกเบี้ยจากการคำนวณได้ดังนี้

TypeScript
1const account = new SavingAccount(100_000);
2console.log(account.balance); // 100000
3console.log(account.interest); // 1250

สิ่งหนึ่งที่เราเห็นแล้วรู้สึกขัดตาคือผลลัพธ์ของตัวเลขแม้เลยจะยาวเพียงใดก็ไม่มีลูกน้ำคั่นซะเลย เราจึงจะทำการแก้ไขโค้ดด้วยการเติม $ เข้าไปนำหน้าจำนวนเงินและทำให้ตัวเลขของเรามีการคั่นลูกน้ำอย่างสวยงาม

TypeScript
1class SavingAccount {
2 static INTEREST_RATE = 0.0125;
3
4 #balance: number;
5
6 constructor(balance: number) {
7 this.#balance = balance;
8 }
9
10 get balance() {
11 return this.#balance.toLocaleString('en-US', {
12 style: 'currency',
13 currency: 'USD',
14 });
15 }
16
17 get interest() {
18 return (this.#balance * SavingAccount.INTEREST_RATE).toLocaleString(
19 'en-US',
20 {
21 style: 'currency',
22 currency: 'USD',
23 }
24 );
25 }
26}
27
28const account = new SavingAccount(100_000);
29console.log(account.balance); // "$100,000.00"
30console.log(account.interest); // "$1,250.00"

ผลลัพธ์จากโค้ดชุดใหม่เป็นอะไรที่ตรงใจมาก ทว่าการเพิ่มโค้ดด้วยการใช้ toLocaleString เพื่อเติมลูกน้ำและดอลล่าร์ ดูจะยุ่งยากเกินไปเมื่อต้องใช้โค้ดชุดนี้กับทุกตำแหน่งที่ต้องการแปลงตัวเลข อย่ากระนั้นเลยเรามาลองใช้ Decorators เพื่อลดความซับซ้อนของโค้ดกันดีกว่า!

TypeScript
1class SavingAccount {
2 static INTEREST_RATE = 0.0125;
3
4 #balance: number;
5
6 constructor(balance: number) {
7 this.#balance = balance;
8 }
9
10 @currency
11 get balance() {
12 return this.#balance;
13 }
14
15 @currency
16 get interest() {
17 return this.#balance * SavingAccount.INTEREST_RATE;
18 }
19}

ส่วนของ @currency ที่เราเห็นเป็นฟังก์ชันที่เราต้องสร้างขึ้นมา ฟังก์ชันประเภทนี้มีหน้าที่ตกแต่งให้ฟังก์ชันของเดิมคือ get balance() และ get interest() มีความสามารถมากขึ้นคือแปลงตัวเลขธรรมดาให้อยู่ในรูปแบบค่าเงิน ฟังก์ชันไม่ธรรมดาที่ขยายความสามารถได้นี้จึงเรียกว่า Decorators

และนี่คือหน้าตาฟังก์ชัน currency ของเราที่เมื่อใส่ไปแล้วจะทำให้การเรียกใช้ balance และ interest ได้ผลลัพธ์เช่นเดิมโดยไม่ต้องไปแก้ไขด้วยการเติม toLocaleString ในโค้ดต้นฉบับเลยครับ

TypeScript
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

TypeScript
1import { Circle } from './circle';
2
3export function dupCircle(circle: Circle) {}

โค้ดข้างต้น TypeScript จะมองว่า Circle เป็นเพียงชนิดข้อมูลไม่จำเป็นต่อการใช้งานใน JavaScript เมื่อทำการ Build ผลลัพธ์ตัวภาษาจึงกำจัดประโยค import ทิ้งเพราะไม่จำเป็นต่อการใช้งาน เราจึงได้โค้ดใหม่ใน JavaScript ดังนี้

JavaScript
1export function dupCircle(circle) {}

เราเรียกพฤติกรรมการตัดประโยค import ในลักษณะนี้ว่า Import Elison

แม้ว่า Import Elison จะเป็นความสามารถที่ดี แต่ถ้าไฟล์ต้นทางของเรามี Side Effect คือกระทำงานบางอย่างเมื่อเรา import เช่นโค้ดต่อไปนี้เมื่อทำการ import ไฟล์จำทำการ setup ฐานข้อมูลโดยอัตโนมัติ

TypeScript
1import { Db } from './db';
2
3export function connect(db: DB) {}
4
5// ไฟล์ db.ts
6
7export type Db = {};
8
9function setupDb() {}
10
11setupDb();

จากโค้ดข้างต้นถ้า TypeScript กระทำ Import Elison นั่นจะทำให้ขั้นตอนการ setup ฐานข้อมูลหายไปทันที สำหรับ TypeScript 5.0 มีออปชั่นใหม่คือ --verbatimModuleSyntax ช่วยแก้ปัญหาความสับสนในจุดนี้ เมื่อใช้ออปชั่นดังกล่าว หากส่วนใดที่เป็นประโยค import แบบ type ส่วนนั้นจะถูกลบออกไป แต่ส่วนอื่นที่ไม่ใช่การ import type จะยังคงอยู่

TypeScript
1// ลบทิ้งทั้งหมดเพราะไม่ได้ใช้เลย
2import type { Circle } from "./circle";
3
4// ผลลัพธ์ใน JavaScript เป็น 'import { PI } from "./circle";'
5import { PI, type Circle } from "./circle";
6
7// ผลลัพธ์ใน JavaScript เป็น 'import {} from "./circle";'
8import { type Circle } from "./circle";

export type *

TypeScript 5.0 ตอนนี้เราสามารถใช้ประโยค export type * เพื่อทำการ export เฉพาะชนิดข้อมูลได้แล้ว

TypeScript
1// models/shapes.ts
2export class Circle {}
3export type CircleColor = 'red' | 'green' | 'blue';
4
5// models/index.ts
6export type * as shapes from './shapes';
7
8// main.ts
9import { shapes } from './models';
10
11// shapes.Circle จะถูกใช้ในฐานะของชนิดข้อมูล
12function cloneCircle(circle: shapes.Circle) {}
13
14function buildCircle() {
15 // ไม่สามารถเรียกใช้งานได้เนื่องจาก Circle ถูก export มาเป็นชนิดข้อมูลเท่านั้น
16 return new shapes.Circle();
17}

การอนุญาตให้มีหลายไฟล์คอนฟิคในส่วนของ extends

TypeScript 5.0 อนุญาตให้เราสามารถ extends หลายไฟล์คอนฟิคได้แล้ว

TypeScript
1// tsconfig.json
2{
3 "extends": ["a", "b", "c"],
4 "compilerOptions": {
5 // ...
6 }
7}

โดยการ extends นั้นหากมีค่าคอนฟิคใดที่ตรงกันในแต่ละไฟล์ ค่าคอนฟิคของไฟล์ล่าสุดในรายการ extends จะเป็นค่าสุดท้ายที่ได้รับเลือก

TypeScript
1// tsconfig.base.json
2{
3 "compilerOptions": {
4 "strictNullChecks": true,
5 "strict": true
6 }
7}
8
9// tsconfig.team.json
10{
11 "compilerOptions": {
12 "noImplicitAny": true,
13 "strict": false
14 }
15}
16
17// tsconfig.json
18{
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 อย่างมืออาชีพ
  • สรุป