Bài 14.4: Module augmentation trong TypeScript

Bài 14.4: Module augmentation trong TypeScript
Chào mừng quay trở lại với chuỗi bài viết của chúng ta về thế giới lập trình web! Hôm nay, chúng ta sẽ khám phá một kỹ thuật mạnh mẽ trong TypeScript, giúp bạn làm việc hiệu quả hơn với các thư viện JavaScript thuần hoặc mở rộng các định nghĩa kiểu (type definitions) hiện có: đó chính là Module Augmentation.
TypeScript mang đến sự an toàn về kiểu dữ liệu cho dự án JavaScript của bạn, nhưng đôi khi, bạn sẽ gặp phải những tình huống cần "dạy" cho TypeScript biết thêm về cấu trúc hoặc thuộc tính mà một module nào đó có, đặc biệt khi bạn đang sử dụng các thư viện JS không có sẵn file định nghĩa kiểu (.d.ts
) hoặc khi bạn cần thêm các thuộc tính tùy chỉnh vào các đối tượng được định nghĩa bởi một module khác. Đây chính là lúc Module Augmentation tỏa sáng!
Vậy, Module Augmentation là gì? Đơn giản mà nói, đó là cách bạn "mở rộng" hoặc "thêm" các định nghĩa kiểu vào một module đã tồn tại. TypeScript cho phép bạn làm điều này bằng cách sử dụng cú pháp declare module
.
Khi bạn sử dụng declare module "module-name" { ... }
, bạn đang nói với TypeScript rằng: "Này, khi bạn thấy module có tên là module-name
, ngoài những gì bạn đã biết về nó, hãy thêm những định nghĩa kiểu mà tôi viết ở đây vào nhé!". Điều quan trọng cần nhớ là Module Augmentation chỉ thêm thông tin về kiểu dữ liệu cho trình biên dịch TypeScript, nó không hề thay đổi mã JavaScript thực tế của module gốc.
Hãy đi sâu vào một vài ví dụ để thấy rõ sức mạnh của kỹ thuật này.
Ví dụ 1: Thêm kiểu cho một thư viện JavaScript "ngây thơ"
Giả sử bạn đang sử dụng một thư viện JavaScript đơn giản không có file .d.ts
.
File my-simple-lib.js
:
// my-simple-lib.js
export function processData(data) {
// Hàm này xử lý dữ liệu, có thể thêm thuộc tính 'status'
if (data && data.value) {
data.status = 'processed';
}
return data;
}
export const config = {
version: '1.0.0'
};
Khi bạn sử dụng nó trong TypeScript:
import { processData, config } from 'my-simple-lib';
const myData = { value: 'test' };
const processed = processData(myData);
// Nếu không có augmentation, TypeScript không biết processed có thuộc tính 'status'
// console.log(processed.status); // Lỗi TS2339: Property 'status' does not exist on type '{ value: string; }'
// TypeScript biết config có version
console.log(config.version); // OK
Để TypeScript biết về thuộc tính status
mà processData
thêm vào, chúng ta sẽ tạo một file định nghĩa kiểu cho module này. Thông thường, bạn sẽ đặt các file augmentation trong một thư mục riêng, ví dụ src/types
hoặc types
, và đảm bảo tsconfig.json
của bạn bao gồm thư mục này.
File src/types/my-simple-lib.d.ts
:
// src/types/my-simple-lib.d.ts
// Sử dụng declare module để mở rộng module 'my-simple-lib'
declare module 'my-simple-lib' {
// Định nghĩa lại hoặc mở rộng các kiểu dữ liệu bên trong module
// Ở đây, chúng ta định nghĩa lại signature của processData
// và mô tả kiểu trả về có thêm thuộc tính 'status'
export function processData<T extends { value: string }>(data: T): T & { status: string };
// Bạn cũng có thể thêm các định nghĩa khác nếu cần
// Ví dụ, nếu thư viện có thêm một hằng số không có trong file JS gốc (ít phổ biến)
// export const NEW_CONSTANT: number;
// Chúng ta không cần khai báo lại config vì nó đã tồn tại và TS có thể suy luận từ JS nếu có file .js và allowJs/checkJs được bật.
// Nhưng nếu muốn làm rõ hoặc bổ sung, bạn cũng có thể khai báo lại:
// export const config: { version: string };
}
Giải thích:
declare module 'my-simple-lib'
: Khai báo rằng chúng ta đang thêm/mở rộng cho module có tên'my-simple-lib'
.export function processData<T extends { value: string }>(data: T): T & { status: string };
: Chúng ta khai báo lại signature của hàmprocessData
.- Sử dụng Generic
<T extends { value: string }>
để đảm bảo kiểu đầu vào có ít nhất thuộc tínhvalue
. - Kiểu trả về là
T & { status: string }
sử dụng Intersection Types, nói rằng kiểu trả về là kiểu gốcT
kết hợp với một object có thuộc tínhstatus
kiểustring
. Điều này phản ánh chính xác hành vi của hàm JS gốc (nó sửa đổi và trả về object đầu vào, thêmstatus
).
- Sử dụng Generic
Bây giờ, khi sử dụng trong file TypeScript:
import { processData, config } from 'my-simple-lib';
const myData = { value: 'test', id: 123 };
const processed = processData(myData);
// Tuyệt vời! TypeScript giờ đã biết processed có thuộc tính status
console.log(processed.status); // 'processed'
console.log(processed.id); // 123
console.log(config.version);
TypeScript giờ đây hoàn toàn "hiểu" cấu trúc của đối tượng processed
nhờ vào định nghĩa kiểu mà chúng ta đã thêm vào module 'my-simple-lib'
.
Ví dụ 2: Mở rộng các đối tượng của Framework (Express)
Đây là một trường hợp sử dụng rất phổ biến của Module Augmentation, đặc biệt khi làm việc với các framework web như Express. Trong middleware hoặc request handler, bạn thường thêm các thuộc tính tùy chỉnh vào đối tượng req
(Request) hoặc res
(Response). Mặc định, TypeScript không biết về những thuộc tính này.
Ví dụ Express thông thường:
import express from 'express';
const app = express();
app.use((req, res, next) => {
// Thêm thuộc tính 'user' và 'sessionId' vào đối tượng request
// req.user = { id: 1, name: 'Alice' }; // Lỗi TS2339: Property 'user' does not exist on type 'Request'.
// req.sessionId = 'abcdef123456'; // Lỗi TS2339: Property 'sessionId' does not exist on type 'Request'.
next();
});
app.get('/', (req, res) => {
// console.log(req.user.name); // Lỗi TS nếu không có augmentation
res.send('Hello');
});
app.listen(3000, () => console.log('Server running'));
Để "dạy" TypeScript về các thuộc tính user
và sessionId
mà chúng ta thêm vào req
, chúng ta cần augment module 'express'
.
File src/types/express.d.ts
:
// src/types/express.d.ts
// Quan trọng: Import module gốc trước khi augment
import 'express';
// Sử dụng declare module để mở rộng module 'express'
declare module 'express' {
// Bên trong module 'express', chúng ta sẽ mở rộng các interfaces có sẵn của nó.
// Đối tượng request có kiểu là Request, nên chúng ta sẽ augment interface Request.
interface Request {
// Thêm các thuộc tính tùy chỉnh của chúng ta vào Request interface
// Sử dụng ? nếu thuộc tính đó là tùy chọn (không phải lúc nào cũng có)
user?: {
id: number;
name: string;
};
sessionId?: string;
// Bạn có thể thêm bất kỳ thuộc tính nào khác mà bạn gắn vào req tại đây
// startTime?: Date;
}
// Nếu bạn thêm thuộc tính vào đối tượng Response, bạn sẽ augment interface Response
// interface Response {
// customMethod?(): void;
// }
// Nếu bạn thêm thuộc tính vào lớp Application, bạn sẽ augment interface Application
// interface Application {
// myConfig?: any;
// }
}
Giải thích:
import 'express';
: Phải import module gốc trước khi augment nó. Điều này giúp TypeScript biết rằng bạn đang muốn thêm vào module đã tồn tại, chứ không phải định nghĩa một module mới hoàn toàn.declare module 'express' { ... }
: Khai báo augmentation cho module'express'
.interface Request { ... }
: Bên trong scope củadeclare module 'express'
, chúng ta khai báo lại interfaceRequest
. TypeScript sẽ tự động kết hợp (merge) định nghĩa interface này với định nghĩaRequest
gốc của Express.user?: { id: number; name: string; };
vàsessionId?: string;
: Chúng ta thêm các định nghĩa cho thuộc tínhuser
vàsessionId
vào interfaceRequest
đã được merge.
Sau khi thêm file src/types/express.d.ts
(và đảm bảo nó được bao gồm trong tsconfig.json
), mã TypeScript Express của bạn sẽ không còn báo lỗi nữa:
import express from 'express';
const app = express();
app.use((req, res, next) => {
// TS giờ đã biết về req.user và req.sessionId!
req.user = { id: 1, name: 'Alice' };
req.sessionId = 'abcdef123456';
console.log(`User ID: ${req.user.id}, Session: ${req.sessionId}`); // OK
next();
});
app.get('/', (req, res) => {
// TS giờ đã biết req.user tồn tại (với kiểu đã định nghĩa)
if (req.user) {
console.log(req.user.name); // OK
}
res.send('Hello');
});
app.listen(3000, () => console.log('Server running'));
Đây là một kỹ thuật cực kỳ hữu ích và cần thiết khi làm việc với các framework cho phép mở rộng các đối tượng request/response.
Ví dụ 3: Mở rộng kiểu dữ liệu có sẵn bên trong một module
Đôi khi, bạn muốn thêm các phương thức hoặc thuộc tính vào một kiểu dữ liệu cụ thể được định nghĩa bên trong một module khác, chứ không phải thêm thuộc tính vào module gốc hay một đối tượng global. Module Augmentation cũng có thể làm điều này.
Giả sử module data-formatter-lib
có một class Formatter
:
// data-formatter-lib.js
export class Formatter {
constructor(data) {
this.data = data;
}
format() {
return `Formatted: ${this.data}`;
}
}
Và bạn muốn thêm một phương thức formatJson
vào tất cả các instance của Formatter
trong dự án của mình (có thể bằng cách thêm vào Formatter.prototype
trong JS).
File src/types/data-formatter-lib.d.ts
:
// src/types/data-formatter-lib.d.ts
import 'data-formatter-lib'; // Import module gốc
// Augment module 'data-formatter-lib'
declare module 'data-formatter-lib' {
// Bên trong scope của module, tìm đến class/interface mà bạn muốn mở rộng
interface Formatter {
// Thêm định nghĩa cho phương thức mới
formatJson(): string;
}
}
Giải thích:
- Tương tự, import module gốc
'data-formatter-lib'
. - Sử dụng
declare module 'data-formatter-lib' { ... }
. - Bên trong, chúng ta khai báo lại interface
Formatter
. TypeScript sẽ merge định nghĩa này với định nghĩa gốc của classFormatter
từ module. formatJson(): string;
: Thêm định nghĩa cho phương thứcformatJson
.
Giờ đây, TypeScript sẽ biết rằng các instance của Formatter
có phương thức formatJson
:
import { Formatter } from 'data-formatter-lib';
const formatter = new Formatter({ name: 'Alice' });
console.log(formatter.format()); // OK
console.log(formatter.formatJson()); // OK, nhờ augmentation
// Bạn vẫn cần thêm cài đặt thực tế cho formatJson trong mã JavaScript của bạn!
// Ví dụ: Formatter.prototype.formatJson = function() { return JSON.stringify(this.data); };
Lưu ý quan trọng: Module Augmentation chỉ thêm thông tin kiểu. Bạn vẫn cần viết mã JavaScript để thêm chức năng formatJson
vào prototype của Formatter
nếu thư viện gốc không cung cấp nó.
Nơi đặt các file Augmentation
Như đã đề cập, cách tốt nhất để tổ chức các file augmentation (.d.ts
) là đặt chúng trong một thư mục riêng biệt, thường là src/types
hoặc types
. Đảm bảo rằng thư mục này được đưa vào mảng include
trong file tsconfig.json
của bạn:
// tsconfig.json
{
"compilerOptions": {
// ... các tùy chọn khác
"baseUrl": "./",
"paths": {
// ...
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.d.ts", // Đảm bảo bao gồm các file .d.ts trong src
"types/**/*.d.ts" // Hoặc bao gồm thư mục types nếu bạn đặt ở đó
],
"exclude": [
"node_modules"
]
}
TypeScript Compiler sẽ tự động tìm kiếm và xử lý các file .d.ts
trong các thư mục được liệt kê trong include
.
Comments