20 Kỹ Thuật TypeScript Thực Tế Mà Mọi Nhà Phát Triển Nên Biết

Trong quá trình xây dựng UserJot – một nền tảng thu thập phản hồi, quản lý lộ trình và cập nhật tính năng cho các đội ngũ SaaS – trong nhiều tháng qua, tôi đã có cơ hội đào sâu vào thế giới TypeScript ở một quy mô lớn chưa từng có. Trước đây, tôi chỉ sử dụng TypeScript một cách rời rạc, nhưng dự án này đã thực sự mở ra một cánh cửa mới.

Sau hàng trăm giờ viết mã nguồn sản phẩm thực tế, gỡ lỗi kiểu dữ liệu phức tạp và tái cấu trúc code để tăng cường an toàn kiểu (type safety), tôi đã tích lũy được nhiều kỹ thuật nhỏ nhưng cực kỳ hữu ích. Những mẹo này không chỉ giúp quá trình phát triển nhanh chóng hơn mà còn bắt được lỗi tiềm ẩn ngay từ giai đoạn phát triển, trước khi chúng kịp gây sự cố trong môi trường production.

UserJot Dashboard

Đây không phải là những lý thuyết suông trong sách vở, mà là các kỹ thuật thực tế được tôi áp dụng hàng ngày, đúc kết từ việc giải quyết các vấn đề cụ thể. Một số mẹo đã cứu tôi khỏi các lỗi runtime khó chịu, trong khi những mẹo khác đơn giản là giúp mã nguồn sạch sẽ, dễ đọc và dễ làm việc hơn. Dưới đây là 20 kỹ thuật TypeScript mà tôi thấy thực sự mang lại giá trị.

1. Sử dụng satisfies để Suy luận Kiểu Tốt hơn

Toán tử satisfies (có từ TypeScript 4.9) cho phép bạn xác thực một biểu thức có khớp với một kiểu nào đó không, đồng thời vẫn giữ nguyên kiểu literal chính xác của các thuộc tính. Điều này cực kỳ hữu ích cho các đối tượng cấu hình (configuration objects), nơi bạn muốn vừa có an toàn kiểu, vừa có khả năng suy luận kiểu chi tiết nhất có thể.

// Không dùng satisfies - Kiểu bị mở rộng (loses specific types)
const config1: Record<string, string | number> = {
  port: 3000,
  host: 'localhost'
};
// Kiểu của config1.port là string | number

// Dùng satisfies - Giữ nguyên kiểu chính xác (keeps specific types)
const config2 = {
  port: 3000,
  host: 'localhost'
} satisfies Record<string, string | number>;
// Kiểu của config2.port là number, kiểu của config2.host là string

Như ví dụ trên, khi không dùng satisfies, TypeScript sẽ suy luận kiểu portstring | number để phù hợp với kiểu đã khai báo cho toàn bộ đối tượng. Tuy nhiên, với satisfies, TypeScript kiểm tra tính tương thích kiểu nhưng vẫn suy luận kiểu chi tiết nhất cho từng thuộc tính (number cho port, string cho host). Điều này giúp bạn truy cập các phương thức cụ thể của kiểu mà không cần ép kiểu.

2. Assertions as const cho Kiểu Bất biến

Thêm as const vào cuối một literal object hoặc array khiến TypeScript coi tất cả các thuộc tính/phần tử bên trong là readonly và suy luận kiểu chi tiết nhất có thể (kiểu literal). Kỹ thuật này rất tuyệt vời cho dữ liệu cấu hình hoặc tập hợp các giá trị cố định mà bạn không muốn thay đổi trong runtime.

const routes = {
  home: '/',
  dashboard: '/dashboard',
  settings: '/settings'
} as const;
// Kiểu của routes.home là '/' thay vì string (general string)
// routes.home = '/new-home'; // Lỗi: Không thể gán lại thuộc tính readonly

Sử dụng as const giúp TypeScript hiểu rõ hơn về giá trị chính xác của dữ liệu, từ đó cải thiện khả năng gợi ý mã (IntelliSense) và phát hiện lỗi khi bạn cố gắng gán lại giá trị cho các thuộc tính/phần tử này.

3. Template Literal Types cho Chuỗi theo Mẫu

Template literal types cho phép bạn định nghĩa các kiểu dữ liệu dựa trên cấu trúc của một chuỗi. Chúng cực kỳ hữu ích khi bạn cần đảm bảo các chuỗi tuân theo một mẫu cụ thể, ví dụ như tên sự kiện, đường dẫn API hoặc các định danh có cấu trúc.

type EventName = `on${Capitalize<string>}`;
// 'onClick', 'onChange', 'onSubmit' là hợp lệ ✅
// 'click', 'handleClick' không hợp lệ ❌

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = `/api/${string}`;
type Route = `${Method} ${Endpoint}`;
// 'GET /api/users' là hợp lệ ✅
// 'GET /users' không hợp lệ ❌

// Ví dụ thực tế
function makeRequest(route: Route) {
  // TypeScript đảm bảo 'route' tuân theo mẫu đã định nghĩa
  console.log(`Thực hiện yêu cầu: ${route}`);
}

makeRequest('GET /api/users'); // ✅ Hợp lệ
// makeRequest('GET /users'); // ❌ Lỗi thời gian biên dịch
// makeRequest('POST /data/items'); // ✅ Hợp lệ
// makeRequest('PUT /api/items/123'); // ✅ Hợp lệ

Kiểu dữ liệu này giúp bạn bắt được lỗi cú pháp của chuỗi ngay ở giai đoạn viết code thay vì gặp lỗi runtime hoặc gửi yêu cầu không đúng định dạng.

4. Discriminated Unions cho Quản lý Trạng thái

Sử dụng một thuộc tính chung (discriminator property) để phân biệt giữa các thành viên trong một union type. TypeScript sẽ dựa vào thuộc tính này để thu hẹp kiểu dữ liệu (type narrowing), giúp mã nguồn của bạn an toàn và dễ quản lý hơn rất nhiều khi làm việc với các trạng thái khác nhau của một đối tượng.

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: string }
  | { status: 'error'; error: Error };

function handleState(state: State) {
  switch (state.status) {
    case 'idle':
      // TypeScript biết 'state' là { status: 'idle' }
      console.log("Hệ thống đang chờ...");
      break;
    case 'loading':
      // TypeScript biết 'state' là { status: 'loading' }
      console.log("Đang tải dữ liệu...");
      break;
    case 'success':
      // TypeScript biết 'state' chắc chắn có thuộc tính 'data'
      console.log("Dữ liệu đã tải xong:", state.data.toUpperCase());
      break;
    case 'error':
      // TypeScript biết 'state' chắc chắn có thuộc tính 'error'
      console.error("Có lỗi xảy ra:", state.error.message);
      break;
  }
}

handleState({ status: 'loading' });
handleState({ status: 'success', data: 'hello world' });
handleState({ status: 'error', error: new Error('Failed to fetch') });

Với discriminated unions, TypeScript có thể hiểu được mối quan hệ giữa thuộc tính phân biệt (status) và sự tồn tại của các thuộc tính khác (data, error). Điều này loại bỏ nhu cầu kiểm tra thủ công sự tồn tại của thuộc tính và giảm thiểu lỗi runtime.

5. Type Predicates cho Type Guards Tùy chỉnh

Type predicates là cách để tạo ra các hàm “kiểm định kiểu” (type guards) tùy chỉnh, giúp bạn “nói” cho TypeScript biết kiểu dữ liệu của một biến là gì sau khi hàm kiểm tra chạy thành công. Phương pháp này giúp mã nguồn sạch sẽ hơn nhiều so với việc rải rác các kiểm tra typeof hoặc instanceof khắp nơi.

// Hàm kiểm định kiểu: nếu trả về true, TypeScript biết 'value' là 'string'
function isString(value: unknown): value is string {
  return typeof value === 'string';
}

function processValue(input: string | number | object) {
  if (isString(input)) {
    // Bên trong khối if này, TypeScript biết 'input' là string
    console.log("Đây là chuỗi:", input.toUpperCase());
  } else {
    // Bên trong khối else này, TypeScript biết 'input' không phải string
    console.log("Đây không phải chuỗi:", input);
  }
}

processValue("hello");
processValue(123);

Sử dụng type predicates giúp tập trung logic kiểm tra kiểu vào một nơi và làm cho mã nguồn sử dụng hàm kiểm tra trở nên rõ ràng, an toàn hơn.

6. Indexed Access Types

Indexed access types (truy cập kiểu bằng chỉ mục) cho phép bạn trích xuất kiểu dữ liệu của một thuộc tính cụ thể từ một kiểu khác bằng cách sử dụng cú pháp dấu ngoặc vuông, giống như cách bạn truy cập giá trị của thuộc tính trong JavaScript runtime.

type User = { id: string; name: string; email: string };
type UserEmail = User['email']; // string
type UserName = User['name'];   // string

// Bạn cũng có thể truy cập kiểu của nhiều thuộc tính bằng union
type UserIdAndEmail = User['id' | 'email']; // string

// Kết hợp với keyof để lấy union của tất cả các key
type UserKeys = keyof User; // 'id' | 'name' | 'email'

Kỹ thuật này giúp giữ cho các định nghĩa kiểu của bạn DRY (Don’t Repeat Yourself) và đảm bảo rằng các kiểu dẫn xuất sẽ tự động cập nhật khi kiểu gốc thay đổi.

7. Conditional Types cho Logic Kiểu Động

Conditional types (kiểu có điều kiện) cho phép bạn tạo ra các kiểu dữ liệu thay đổi dựa trên một điều kiện nhất định, thường là mối quan hệ subtype giữa hai kiểu. Hãy xem chúng như toán tử ba ngôi (ternary operator) ở cấp độ kiểu dữ liệu.

// Kiểu kiểm tra xem T có phải là một mảng không
type IsArray<T> = T extends any[] ? true : false;

type Test1 = IsArray<string[]>; // Test1 có kiểu là true
type Test2 = IsArray<string>;   // Test2 có kiểu là false

// Kiểu để "làm phẳng" một mảng (trích xuất kiểu phần tử), nếu không phải mảng thì giữ nguyên
type Flatten<T> = T extends Array<infer U> ? U : T;

type Flattened1 = Flatten<string[]>; // Flattened1 có kiểu là string
type Flattened2 = Flatten<number>;  // Flattened2 có kiểu là number

// Ví dụ thực tế hơn: Định nghĩa kiểu phản hồi API
type ApiResponse<T> = T extends { error: string }
  ? { success: false; error: string } // Nếu T có thuộc tính 'error' kiểu string
  : { success: true; data: T };       // Ngược lại, giả định là dữ liệu thành công

type SuccessResponse = ApiResponse<{ id: number, name: string }>;
// { success: true; data: { id: number, name: string } }

type ErrorResponse = ApiResponse<{ error: string, code: number }>;
// { success: false; error: string } (chỉ lấy phần error vì T extends { error: string })

Conditional types, đặc biệt khi kết hợp với từ khóa infer, mở ra khả năng tạo ra các kiểu dữ liệu cực kỳ linh hoạt và phức tạp dựa trên cấu trúc của các kiểu khác.

8. Utility Types – Những Người Bạn Thân

TypeScript cung cấp một bộ sưu tập các “utility types” (kiểu tiện ích) được tích hợp sẵn để giải quyết các vấn đề phổ biến trong việc biến đổi hoặc kết hợp kiểu. Thay vì tự định nghĩa lại, hãy làm quen và sử dụng chúng.

type User = { id: string; name: string; email: string; age?: number };

// Partial: Làm cho tất cả các thuộc tính trở thành optional
type PartialUser = Partial<User>;
// { id?: string; name?: string; email?: string; age?: number }

// Readonly: Làm cho tất cả các thuộc tính trở thành readonly
type ReadonlyUser = Readonly<User>;
// { readonly id: string; readonly name: string; readonly email: string; readonly age?: number }

// Omit: Loại bỏ một hoặc nhiều thuộc tính
type UserWithoutEmail = Omit<User, 'email'>;
// { id: string; name: string; age?: number }

// Pick: Chỉ giữ lại một hoặc nhiều thuộc tính
type JustEmailAndId = Pick<User, 'email' | 'id'>;
// { email: string; id: string }

// Record: Tạo một kiểu object với key và value có kiểu xác định
type Config = Record<string, string | number>;
// { [key: string]: string | number }

// Exclude: Loại bỏ các kiểu từ một union type
type Colors = 'red' | 'green' | 'blue' | 'yellow';
type PrimaryColors = Exclude<Colors, 'yellow' | 'blue'>; // 'red' | 'green'

Việc nắm vững các utility types giúp bạn viết mã nguồn ngắn gọn, dễ đọc và tận dụng được sức mạnh của hệ thống kiểu TypeScript.

9. Function Overloads để Cải thiện Trải nghiệm Lập trình (DX)

Function overloads cho phép bạn định nghĩa nhiều “signature” (chữ ký hàm) khác nhau cho cùng một hàm dựa trên các kiểu đối số đầu vào hoặc kiểu trả về khác nhau. Điều này mang lại trải nghiệm tốt hơn cho người sử dụng hàm của bạn, với gợi ý mã (autocomplete) chính xác và kiểm tra kiểu chặt chẽ hơn.

// Các chữ ký overload (visible to consumers)
function parse(value: string): object;
function parse(value: string, reviver: (key: any, value: any) => any): object;

// Chữ ký triển khai (implementation signature - not visible to consumers)
function parse(value: string, reviver?: (key: any, value: any) => any): object {
  return JSON.parse(value, reviver);
}

// Sử dụng sẽ nhận được gợi ý kiểu phù hợp
const obj1 = parse('{}'); // Kiểu trả về là object
const obj2 = parse('{"a": 1}', (k, v) => typeof v === 'number' ? v * 2 : v); // Biết rằng có thể truyền reviver

// Một ví dụ khác với kiểu trả về khác nhau
function createElement(tag: 'img'): HTMLImageElement;
function createElement(tag: 'input'): HTMLInputElement;
function createElement(tag: string): HTMLElement; // Chữ ký tổng quát
function createElement(tag: string): HTMLElement {
  // Đây là phần triển khai thực tế, không cần khớp hoàn hảo với các overload signatures
  return document.createElement(tag);
}

const img = createElement('img');   // img có kiểu là HTMLImageElement
const input = createElement('input'); // input có kiểu là HTMLInputElement
const div = createElement('div');   // div có kiểu là HTMLElement

Overloading giúp API hàm của bạn rõ ràng hơn về các trường hợp sử dụng được hỗ trợ và cải thiện đáng kể khả năng sử dụng.

10. Generic Constraints (Ràng buộc Generic)

Khi làm việc với generics, bạn thường muốn hạn chế các kiểu dữ liệu có thể được sử dụng làm tham số kiểu. Generic constraints cho phép bạn chỉ định các ràng buộc này, ngăn ngừa các lỗi tiềm ẩn và cung cấp thông tin kiểu chính xác hơn cho trình biên dịch và IDE.

// Hàm lấy giá trị của một thuộc tính từ một đối tượng
// T là kiểu của đối tượng, K là kiểu của key (phải là một trong các key của T)
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: 'Alice', age: 30 };

const userName = getProperty(user, 'name'); // userName có kiểu là string
const userAge = getProperty(user, 'age');   // userAge có kiểu là number

// getProperty(user, 'address'); // Lỗi: 'address' không phải là key của { name: string, age: number }

Ràng buộc K extends keyof T đảm bảo rằng đối số key phải là một khóa hợp lệ của đối tượng obj, giúp bạn tránh các lỗi truy cập thuộc tính không tồn tại.

11. Mapped Types cho Biến đổi Kiểu có Hệ thống

Mapped types cho phép bạn lặp qua các thuộc tính của một kiểu hiện có và biến đổi chúng một cách có hệ thống để tạo ra một kiểu mới. Kỹ thuật này rất mạnh mẽ để tạo ra các biến thể của các kiểu dữ liệu sẵn có.

// Tạo một kiểu mới mà tất cả các thuộc tính của T đều là nullable (có thể là T[K] hoặc null)
type Nullable<T> = { [K in keyof T]: T[K] | null };

type User = { id: string; name: string; age: number };
type NullableUser = Nullable<User>;
// NullableUser có kiểu là: { id: string | null; name: string | null; age: number | null }

// Tạo các getter methods từ các thuộc tính của T
type Getters<T> = {
  // Ánh xạ lại tên key: thêm 'get' và viết hoa chữ cái đầu của key gốc
  [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K]
};

type UserGetters = Getters<User>;
// UserGetters có kiểu là: { getId: () => string; getName: () => string; getAge: () => number }

// Loại bỏ modifier 'readonly'
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

type Point = { readonly x: number; readonly y: number };
type MutablePoint = Mutable<Point>;
// MutablePoint có kiểu là: { x: number; y: number } (readonly đã bị loại bỏ)

Mapped types là nền tảng của nhiều utility types có sẵn trong TypeScript và cho phép bạn tạo ra các biến đổi kiểu phức tạp theo nhu cầu cụ thể của ứng dụng.

12. Kiểu never cho Kiểm tra Tất cả các Trường hợp (Exhaustive Checks)

Kiểu never đại diện cho tập hợp các giá trị rỗng. Nó thường được sử dụng để đánh dấu các vị trí trong mã mà về mặt lý thuyết, không bao giờ có thể đạt tới. Một trong những ứng dụng phổ biến và mạnh mẽ nhất của never là để đảm bảo bạn đã xử lý *tất cả* các trường hợp có thể xảy ra trong một khối switch hoặc chuỗi if/else if.

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: string }
  | { status: 'error'; error: Error };

function handleStatus(status: State['status']) {
  switch (status) {
    case 'idle':
      console.log("Trạng thái chờ...");
      return;
    case 'loading':
      console.log("Trạng thái đang tải...");
      return;
    case 'success':
      console.log("Trạng thái thành công.");
      return;
    case 'error':
      console.log("Trạng thái lỗi.");
      return;
    default:
      // Gán biến '_exhaustive' với kiểu 'never'.
      // Nếu có một trường hợp 'status' mới được thêm vào union State['status']
      // mà chưa được xử lý trong switch, trình biên dịch sẽ báo lỗi ở đây
      // vì kiểu của 'status' không còn là 'never'.
      const _exhaustiveCheck: never = status;
      throw new Error(`Trạng thái chưa xử lý: ${_exhaustiveCheck}`);
  }
}

Bằng cách thêm case default sử dụng never, TypeScript sẽ cảnh báo bạn ngay tại thời điểm biên dịch nếu bạn quên xử lý bất kỳ trường hợp nào trong union type, giúp mã nguồn của bạn trở nên vững chắc hơn khi các kiểu dữ liệu phát triển.

13. Module Augmentation (Mở rộng Module)

Module augmentation cho phép bạn thêm các định nghĩa kiểu mới hoặc sửa đổi các định nghĩa kiểu hiện có trong các module JavaScript/TypeScript đã tồn tại, bao gồm cả các module của bên thứ ba hoặc các môi trường global như window.

// Giả sử bạn có một thư viện analytics và muốn thêm nó vào đối tượng window global
interface AnalyticsClient {
  track(event: string, properties?: object): void;
}

declare global {
  // Mở rộng interface Window global
  interface Window {
    analytics: AnalyticsClient;
    // Thêm các thuộc tính global khác nếu cần
    myAppData?: { userId: string };
  }
}

// Bây giờ bạn có thể truy cập window.analytics và window.myAppData
// với kiểm tra kiểu chính xác mà không cần ép kiểu (casting) thủ công.
window.analytics.track('UserLoggedIn', { userId: '123' });
const userId = window.myAppData?.userId; // userId có kiểu string | undefined

Kỹ thuật này rất hữu ích khi bạn cần tích hợp TypeScript với các thư viện JavaScript cũ không có file định nghĩa kiểu hoặc khi bạn muốn thêm các biến global tùy chỉnh vào ứng dụng của mình.

14. Type-Only Imports (Import Chỉ dành cho Kiểu)

Trong các phiên bản TypeScript cũ hơn, ngay cả khi bạn chỉ import một tên dành cho kiểu (như interface, type alias), TypeScript vẫn tạo ra mã JavaScript cho lệnh import đó. Điều này đôi khi có thể gây ra vấn đề như circular dependencies hoặc làm tăng kích thước bundle một cách không cần thiết.

Từ khóa type trong lệnh import giải quyết vấn đề này. Nó báo cho TypeScript rằng lệnh import này chỉ được sử dụng cho mục đích kiểm tra kiểu và có thể bị loại bỏ hoàn toàn trong quá trình biên dịch JavaScript.

// Chỉ import kiểu User, sẽ bị loại bỏ trong output JavaScript
import type { User } from './types';

// Import cả kiểu Config và hàm validateConfig
import { type Config, validateConfig } from './config';

function processUser(user: User) {
  // ... sử dụng kiểu User ...
}

function setupConfig(config: Config) {
  validateConfig(config);
  // ... sử dụng kiểu Config ...
}

Sử dụng import type là một cách hay để làm rõ ý định của bạn và giúp tối ưu hóa mã nguồn đầu ra.

15. Assert Functions (Hàm Khẳng định)

Assert functions (có từ TypeScript 3.7) là các hàm đặc biệt được sử dụng để khẳng định một điều kiện nào đó là đúng. Nếu điều kiện sai, hàm sẽ ném ra một ngoại lệ. Điểm mạnh của chúng là sau khi một assert function được gọi thành công, TypeScript có thể thu hẹp kiểu dữ liệu (narrow the type) của biến được kiểm tra.

// Hàm khẳng định rằng giá trị không phải undefined
function assertDefined<T>(value: T | undefined | null): asserts value is T {
  if (value === undefined || value === null) {
    throw new Error('Giá trị không được là undefined hoặc null');
  }
}

// Sử dụng assert function
function processUser(user: { name?: string } | undefined | null) {
  assertDefined(user);
  // Sau dòng này, TypeScript biết 'user' chắc chắn là { name?: string }
  // mà không phải undefined | null nữa.

  // Bây giờ bạn có thể truy cập user mà không cần kiểm tra null/undefined thêm
  console.log("Tên người dùng:", user.name); // user.name vẫn có thể là string | undefined

  // Bạn có thể kết hợp nhiều khẳng định
  assertDefined(user.name);
  // Sau dòng này, TypeScript biết 'user.name' chắc chắn là string
  console.log("Tên người dùng (không null):", user.name.toUpperCase()); // An toàn để gọi toUpperCase()
}

processUser({ name: 'Alice' }); // Chạy bình thường
// processUser(undefined); // Ném lỗi "Giá trị không được là undefined hoặc null"
// processUser({ }); // Ném lỗi "Giá trị không được là undefined hoặc null" (cho user.name)

Assert functions cực kỳ hữu ích cho việc kiểm tra dữ liệu đầu vào (ví dụ: từ API hoặc form) tại runtime, đồng thời cung cấp thông tin kiểu chính xác cho TypeScript, giảm nhu cầu sử dụng ép kiểu không an toàn.

16. Branded Types cho An toàn Kiểu ở Runtime (theo cấu trúc)

Branded types là một mẫu thiết kế trong TypeScript cho phép bạn tạo ra các kiểu dữ liệu mà về mặt cấu trúc thì giống hệt nhau (ví dụ: đều là string hoặc number) nhưng lại khác nhau về mặt danh nghĩa (nominally different). Điều này giúp ngăn ngừa việc nhầm lẫn giữa các giá trị có cùng kiểu cơ bản nhưng ý nghĩa khác nhau, ví dụ như nhầm lẫn giữa ID người dùng và ID bài viết khi cả hai đều là chuỗi.

// Định nghĩa Branded Types sử dụng giao (intersection) với một thuộc tính giả '__brand'
type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };

// Các hàm trợ giúp để tạo branded types (thực hiện ép kiểu không an toàn ở đây)
function userId(id: string): UserId {
  return id as UserId;
}

function postId(id: string): PostId {
  return id as PostId;
}

// Các hàm sử dụng branded types
function getUserById(id: UserId) {
  console.log("Tìm người dùng với ID:", id);
  // Logic truy vấn database...
}

function getPostById(id: PostId) {
  console.log("Tìm bài viết với ID:", id);
  // Logic truy vấn database...
}

// Sử dụng
const uId = userId('user_123');
const pId = postId('post_456');

getUserById(uId); // ✅ Hợp lệ
// getUserById(pId); // ❌ Lỗi thời gian biên dịch: Kiểu 'PostId' không thể gán cho kiểu 'UserId'

getPostById(pId); // ✅ Hợp lệ
// getPostById(uId); // ❌ Lỗi thời gian biên dịch

Mặc dù cần một chút ép kiểu ban đầu để tạo ra các branded values, nhưng sau đó, TypeScript sẽ kiểm tra chặt chẽ việc sử dụng chúng, giúp bạn bắt được nhiều lỗi logic tiềm ẩn liên quan đến nhầm lẫn dữ liệu.

17. Mẫu Builder với Fluent Interface

Khi xây dựng các đối tượng phức tạp yêu cầu nhiều bước cấu hình hoặc gọi các phương thức theo một trình tự cụ thể, mẫu Builder kết hợp với generics trong TypeScript có thể đảm bảo an toàn kiểu cho “fluent interface” (giao diện gọi chuỗi phương thức).

// Sử dụng generics để theo dõi các bước cấu hình đã được thực hiện
class QueryBuilder<T = {}> {
  private query: any = {};

  select<K extends string>(field: K): QueryBuilder<T & { select: K }> {
    this.query.select = field;
    // Ép kiểu 'this' để TypeScript cập nhật kiểu generic
    return this as any;
  }

  where<K extends string>(field: K): QueryBuilder<T & { where: K }> {
    this.query.where = field;
    return this as any;
  }

  // Hàm build chỉ khả dụng khi kiểu generic T bao gồm cả 'select' và 'where'
  build(this: QueryBuilder<{ select: string; where: string }>): any {
    console.log("Xây dựng query:", this.query);
    return this.query;
  }
}

// Sử dụng đúng trình tự
const query = new QueryBuilder()
  .select('name') // Trả về QueryBuilder<{ select: 'name' }>
  .where('id')    // Trả về QueryBuilder<{ select: 'name', where: 'id' }>
  .build();       // ✅ Hợp lệ vì đã có cả 'select' và 'where'

// Sử dụng sai trình tự
const badQuery = new QueryBuilder()
  .select('name') // Trả về QueryBuilder<{ select: 'name' }>
  // .where('id') // Bỏ qua bước where
  // .build();    // ❌ Lỗi thời gian biên dịch: build() yêu cầu generic type phải có 'select: string' và 'where: string'

Mẫu này đảm bảo rằng người dùng lớp Builder của bạn tuân thủ đúng “protocol” (các bước cần thiết) ngay tại thời điểm biên dịch, giảm thiểu lỗi runtime do thiếu cấu hình.

18. Const Enums cho Abstraction “Zero-Cost”

Const enums là một loại enum đặc biệt trong TypeScript. Chúng hoàn toàn bị loại bỏ trong quá trình biên dịch và các thành viên của enum được thay thế trực tiếp bằng giá trị literal của chúng trong mã JavaScript đầu ra. Điều này không tạo ra bất kỳ runtime overhead nào.

const enum LogLevel {
  Debug = 0,
  Info = 1,
  Warn = 2,
  Error = 3
}

function log(level: LogLevel, message: string) {
  if (level >= LogLevel.Warn) { // LogLevel.Warn sẽ được thay thế bằng 2 trong mã JS
    console.log(`[${LogLevel[level]}] ${message}`); // LogLevel[level] không hoạt động trực tiếp với const enum nếu target module không phù hợp
  }
}

log(LogLevel.Error, "Có lỗi nghiêm trọng!");

Lưu ý: Const enums có một số hạn chế so với enums thông thường, ví dụ, bạn không thể truy cập tên thành viên (như LogLevel[level]) một cách dễ dàng nếu không có thiết lập biên dịch phù hợp (ví dụ: isolatedModules: false). Tuy nhiên, đối với các trường hợp đơn giản chỉ cần giá trị số hoặc chuỗi cố định, const enums mang lại lợi ích về hiệu suất.

19. Intersection Types cho Kết hợp Kiểu

Intersection types (kiểu giao) cho phép bạn kết hợp nhiều kiểu dữ liệu thành một kiểu duy nhất bao gồm *tất cả* các thuộc tính từ các kiểu gốc. Kỹ thuật này linh hoạt hơn extends khi bạn muốn kết hợp các kiểu không có quan hệ kế thừa rõ ràng.

type Timestamped = { createdAt: Date; updatedAt: Date };
type Authored = { authorId: string };
type PostContent = { title: string; content: string };

// Kiểu Post là sự kết hợp của PostContent, Timestamped và Authored
type Post = PostContent & Timestamped & Authored;

const myPost: Post = {
  title: "Bài viết đầu tiên",
  content: "Nội dung...",
  createdAt: new Date(),
  updatedAt: new Date(),
  authorId: "user_123"
};

// Bạn cũng có thể kết hợp kiểu của các hàm
type Callback<T> = (data: T) => void;
type ErrorHandler = (error: Error) => void;

type Handlers<T> = Callback<T> & ErrorHandler;

function processDataWithHandlers(data: any, handlers: Handlers<any>) {
   try {
     // Gọi phần Callback
     handlers(data);
   } catch (error: any) {
     // Gọi phần ErrorHandler
     handlers(error); // TypeScript hiểu handlers là cả Callback và ErrorHandler
   }
}

Intersection types là một công cụ mạnh mẽ để xây dựng các kiểu dữ liệu phức tạp từ các kiểu đơn giản hơn thông qua cơ chế “composition” (kết hợp).

20. Utility Type NoInfer

Mới ra mắt trong TypeScript 5.4, utility type NoInfer cho phép bạn ngăn chặn suy luận kiểu (type inference) ở một vị trí cụ thể trong generic type parameter. Điều này hữu ích khi bạn có nhiều đối số trong một hàm generic và muốn kiểu của tham số generic chỉ được suy luận từ một hoặc một vài đối số nhất định, tránh việc kiểu bị mở rộng ngoài ý muốn.

// Không dùng NoInfer - T được suy luận từ cả hai đối số
function createState<T>(initial: T, actions: T) {
  return { state: initial, actions };
}

// Vấn đề: T trở thành string | number thay vì chỉ string vì 42 có kiểu number
const state1 = createState('hello', 42);
// state1 có kiểu { state: string | number, actions: string | number } - Không mong muốn

// Với NoInfer - T chỉ được suy luận từ đối số 'initial'
function createStateSafe<T>(initial: T, actions: NoInfer<T>) {
  return { state: initial, actions };
}

// Bây giờ, dòng này báo lỗi như mong đợi
// const state2 = createStateSafe('hello', 42);
// ❌ Lỗi thời gian biên dịch: Đối số '42' có kiểu number, nhưng kiểu mong đợi là string (suy luận từ 'hello')

// Để sử dụng với kiểu khác nhau, bạn phải chỉ định rõ tham số T
const state3 = createStateSafe<string | number>('hello', 42); // ✅ Hợp lệ
// state3 có kiểu { state: string | number, actions: string | number }

NoInfer là một công cụ nâng cao, hữu ích trong các kịch bản phức tạp liên quan đến suy luận kiểu generic, giúp bạn kiểm soát chặt chẽ hơn hành vi của trình biên dịch.

Phần Kết luận

Trên đây là 20 kỹ thuật TypeScript mà tôi đã áp dụng và thấy chúng mang lại hiệu quả rõ rệt trong công việc hàng ngày. Chúng không chỉ giúp tôi bắt lỗi sớm hơn mà còn làm cho mã nguồn dự án UserJot dễ hiểu, dễ bảo trì và an toàn hơn đáng kể.

TypeScript là một công cụ mạnh mẽ với rất nhiều tính năng, nhưng bạn không nhất thiết phải sử dụng tất cả. Hãy chọn lọc những kỹ thuật thực sự giải quyết vấn đề bạn đang gặp phải. Bắt đầu với một vài mẹo đơn giản, làm quen dần, sau đó từ từ thêm chúng vào bộ công cụ của mình.

Nếu bạn đang phát triển một sản phẩm và cần một giải pháp hiệu quả để thu thập phản hồi từ người dùng, quản lý lộ trình phát triển sản phẩm (product roadmap) hoặc thông báo cập nhật tính năng thông qua changelogs, hãy dùng thử UserJot. Đây chính là dự án tôi đã xây dựng và áp dụng rất nhiều kỹ thuật TypeScript được đề cập trong bài viết này. UserJot được thiết kế để giúp các đội ngũ xây dựng những sản phẩm mà người dùng thực sự mong muốn.

UserJot feedback board with user discussions

20 Kỹ Thuật TypeScript Thực Tế Mà Mọi Nhà Phát Triển Nên Biết

Chỉ mục