Viết thư viện TypeScript có type declaration
1. Tổng quan
Người dùng thư viện TypeScript cần hai thứ: code JavaScript để chạy và file type declaration (.d.ts) để editor gợi ý, còn compiler thì bắt lỗi. Nếu thiếu .d.ts, hoặc package.json trỏ sai file, người dùng sẽ nhận được any ở khắp nơi hoặc lỗi Could not find a declaration file for module.
Bài này xây dựng một thư viện nhỏ tên order-pricing, dùng để tính tiền đơn hàng. Đọc xong, bạn sẽ biết cách cấu hình tsconfig.json để sinh .d.ts, khai báo exports trong package.json, viết declaration thủ công cho code JavaScript cũ, và kiểm thử cả runtime lẫn type trước khi publish.
2. Khởi tạo dự án
Bạn tạo thư mục và cài các công cụ cần thiết:
mkdir order-pricing && cd order-pricing
npm init -y
npm install --save-dev typescript vitest
typescript chỉ cần ở devDependencies. Người dùng thư viện không cần compiler, họ chỉ dùng file .js và .d.ts đã build sẵn.
2.1. Cấu hình tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
"isolatedDeclarations": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
Ý nghĩa của các option quan trọng:
declaration: trueyêu cầutscsinh file.d.tsbên cạnh mỗi file.js. Đây là option cốt lõi của bài.declarationMap: truesinh file.d.ts.map. Khi người dùng bấm “Go to Definition”, editor sẽ mở file.tsgốc thay vì file.d.ts. Muốn tính năng này hoạt động, bạn cần publish kèm thư mụcsrc.modulevàmoduleResolutionđặt làNodeNextđể TypeScript phân giải module giống hệt Node.js. Thư viện build theo cách này dùng được ở cả bundler lẫn Node.isolatedDeclarations(có từ TypeScript 5.5) bắt buộc mọi API được export phải khai báo kiểu rõ ràng, ví dụ kiểu trả về của function. Nhờ đó, các công cụ khác có thể sinh.d.tsmà không cần chạy type checker. API công khai cũng trở nên tường minh hơn.excludebỏ file test ra khỏi bản build, để test không lọt vàodist.
2.2. Cấu hình package.json
{
"name": "order-pricing",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src"],
"scripts": {
"build": "tsc",
"test": "vitest run",
"prepublishOnly": "npm run test && npm run build"
}
}
Các điểm cần chú ý:
"type": "module"khiến các file.jstrongdistđược Node hiểu là ESM.- Trong
exports, điều kiệntypesphải đứng đầu. TypeScript duyệt các điều kiện theo thứ tự và dừng ở điều kiện khớp đầu tiên. Nếudefaultđứng trước, TypeScript sẽ không bao giờ đọc tớitypes. mainvàtypesở cấp gốc là phương án dự phòng cho công cụ cũ không đọcexports.filesgiới hạn những gì được đưa lên npm. Thiếu field này, bạn dễ publish nhầm cảnode_moduleshoặc file cấu hình nội bộ.
3. Viết code thư viện
3.1. Định nghĩa kiểu dữ liệu
// src/types.ts
export type CustomerTier = "regular" | "silver" | "gold";
export interface Customer {
id: string;
name: string;
tier: CustomerTier;
}
export interface OrderItem {
sku: string;
unitPrice: number;
quantity: number;
}
export interface Order {
id: string;
customer: Customer;
items: readonly OrderItem[];
}
items được khai báo là readonly OrderItem[]. Nhờ vậy, người dùng truyền vào cả mảng thường lẫn mảng readonly đều hợp lệ. Thư viện cũng tự cam kết không sửa mảng của người dùng.
3.2. Logic tính tiền
// src/pricing.ts
import type { CustomerTier, Order } from "./types.js";
const DISCOUNT_RATE: Record<CustomerTier, number> = {
regular: 0,
silver: 0.05,
gold: 0.1,
};
/** Tổng tiền hàng trước giảm giá, đơn vị VND. */
export function calculateSubtotal(order: Order): number {
return order.items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0,
);
}
/** Tổng tiền sau khi áp dụng giảm giá theo hạng khách hàng. */
export function calculateTotal(order: Order): number {
const subtotal = calculateSubtotal(order);
const discount = subtotal * DISCOUNT_RATE[order.customer.tier];
return Math.round(subtotal - discount);
}
Trong đoạn code trên:
import typebáo cho compiler rằng đây chỉ là import kiểu. Dòng này bị xóa hoàn toàn khỏi file.jssau khi build.- Đường dẫn import viết là
./types.jsdù file nguồn làtypes.ts. VớiNodeNext, đây là cách viết bắt buộc, vì Node sẽ tìm file.jskhi chạy. DISCOUNT_RATEkhông được export, nên nó là chi tiết nội bộ và không xuất hiện trong.d.ts.- Comment dạng
/** ... */được giữ nguyên trong.d.ts. Người dùng sẽ thấy nội dung này khi hover trong editor.
3.3. Điểm vào (entry point)
// src/index.ts
export { calculateSubtotal, calculateTotal } from "./pricing.js";
export type { Customer, CustomerTier, Order, OrderItem } from "./types.js";
index.ts quyết định API công khai của thư viện. Bạn nên export cả các kiểu dữ liệu. Nếu không, người dùng sẽ phải viết Parameters<typeof calculateTotal>[0] chỉ để lấy được kiểu Order.
4. Build và xem file .d.ts
npm run build
Cấu trúc thư mục dist sau khi build:
dist/
├── index.d.ts
├── index.d.ts.map
├── index.js
├── index.js.map
├── pricing.d.ts
├── pricing.d.ts.map
├── pricing.js
├── pricing.js.map
├── types.d.ts
├── types.d.ts.map
├── types.js
└── types.js.map
Nội dung dist/pricing.d.ts:
import type { Order } from "./types.js";
/** Tổng tiền hàng trước giảm giá, đơn vị VND. */
export declare function calculateSubtotal(order: Order): number;
/** Tổng tiền sau khi áp dụng giảm giá theo hạng khách hàng. */
export declare function calculateTotal(order: Order): number;
//# sourceMappingURL=pricing.d.ts.map
File này chỉ chứa chữ ký (signature), không chứa phần thân hàm. DISCOUNT_RATE cũng không có mặt. dist/types.js gần như rỗng (chỉ còn export {};), vì interface và type alias không tồn tại ở runtime.
Nếu bạn bỏ kiểu trả về : number ở calculateTotal, option isolatedDeclarations sẽ báo lỗi:
src/pricing.ts(20,17): error TS9007: Function must have an explicit return type annotation with --isolatedDeclarations.
Lỗi này có lợi cho bạn. Nếu kiểu trả về được suy luận tự động, một thay đổi nhỏ trong thân hàm có thể vô tình đổi API công khai mà bạn không hề hay biết.
5. Kiểm thử runtime và type
Một thư viện có type cần được test ở hai mặt: hàm trả về đúng giá trị, và kiểu dữ liệu đúng như bạn cam kết. Vitest hỗ trợ cả hai.
// src/pricing.test.ts
import { describe, expect, expectTypeOf, it } from "vitest";
import { calculateSubtotal, calculateTotal, type Order } from "./index.js";
const goldOrder: Order = {
id: "ORD-001",
customer: { id: "C-01", name: "Lan", tier: "gold" },
items: [
{ sku: "TEA-01", unitPrice: 150_000, quantity: 2 },
{ sku: "CUP-07", unitPrice: 200_000, quantity: 1 },
],
};
describe("calculateTotal", () => {
it("tính tổng trước giảm giá", () => {
expect(calculateSubtotal(goldOrder)).toBe(500_000);
});
it("giảm 10% cho khách hạng gold", () => {
expect(calculateTotal(goldOrder)).toBe(450_000);
});
it("có chữ ký kiểu đúng như cam kết", () => {
expectTypeOf(calculateTotal).parameter(0).toEqualTypeOf<Order>();
expectTypeOf(calculateTotal).returns.toBeNumber();
});
it("không chấp nhận hạng khách hàng lạ", () => {
// @ts-expect-error "diamond" không thuộc CustomerTier
const tier: Order["customer"]["tier"] = "diamond";
expect(tier).toBe("diamond");
});
});
Phân tích từng test:
- Hai test đầu kiểm tra logic: 2 × 150.000 + 200.000 = 500.000, sau khi giảm 10% còn 450.000.
expectTypeOfkhóa chữ ký của hàm. Nếu sau này ai đó đổi kiểu trả về thànhstring, test này sẽ fail.@ts-expect-errorkhẳng định dòng bên dưới phải gây lỗi type. Nếu một ngày"diamond"trở nên hợp lệ, TypeScript sẽ báo directive này thừa.
Chạy test:
npm test
✓ src/pricing.test.ts (4 tests) 3ms
Test Files 1 passed (1)
Tests 4 passed (4)
Lưu ý: Mặc định Vitest chỉ transpile code chứ không type-check.
@ts-expect-errorvà các lỗi type chỉ được kiểm tra khi bạn chạytsc --noEmit -p tsconfig.test.json(một config riêng có include file test) hoặcvitest --typecheck. Bạn nên đưa bước này vào CI.
5.1. Kiểm tra gói trước khi publish
npm pack --dry-run
npm notice 📦 order-pricing@1.0.0
npm notice Tarball Contents
npm notice 1.1kB dist/index.d.ts
npm notice 312B dist/index.js
...
npm notice 945B src/pricing.ts
npm notice 402B src/types.ts
Bạn đọc danh sách này để chắc chắn có đủ .d.ts và không có file test hay file bí mật nào. Cách kiểm tra thực tế nhất là chạy npm pack, rồi cài file .tgz vào một dự án mẫu và import thử. Công cụ @arethetypeswrong/cli (lệnh attw --pack) cũng phát hiện được phần lớn lỗi cấu hình exports và types.
6. Viết declaration thủ công cho code JavaScript
Đôi khi thư viện còn giữ một module JavaScript cũ chưa kịp chuyển sang TypeScript. Bạn có thể tự viết .d.ts cho module đó.
// src/legacy-tax.js
export function applyVat(amount, rate = 0.08) {
return Math.round(amount * (1 + rate));
}
// src/legacy-tax.d.ts
/**
* Cộng thuế VAT vào số tiền.
* @param amount Số tiền trước thuế (VND).
* @param rate Thuế suất, mặc định 0.08.
*/
export declare function applyVat(amount: number, rate?: number): number;
Sau đó bạn export lại từ index.ts như module bình thường:
export { applyVat } from "./legacy-tax.js";
Lưu ý:
tsckhông copy file.jsvà.d.tsviết tay sangdist. Bạn có hai lựa chọn. Cách thứ nhất là bậtallowJs: trueđểtsccopylegacy-tax.jssangdist, sau đó copy thêmlegacy-tax.d.tsbằng một script, ví dụ"build": "tsc && cp src/legacy-tax.d.ts dist/". Cách thứ hai là bậtallowJskèm JSDoc và đểtsctự sinh.d.tstừ JSDoc. Đây là lỗi rất phổ biến: chạy local thì ổn, nhưng sau khi publish người dùng lại nhận lỗi thiếu module.
Nếu bạn cần bổ sung type cho một package bên thứ ba không có sẵn type, hãy dùng declare module:
// src/shims/vn-currency.d.ts
declare module "vn-currency" {
export function format(amount: number): string;
}
Bạn chỉ nên làm vậy trong nội bộ dự án. Nếu API công khai của thư viện phụ thuộc vào shim này, người dùng sẽ không nhìn thấy nó.
7. Lỗi hay gặp
typesđứng saudefaulttrongexports: TypeScript không đọc tớitypes, nên người dùng nhậnanyhoặc lỗi TS7016.- Quên đuôi
.jskhi import vớiNodeNext:tscbáo lỗi TS2835. Nếu bạn né lỗi bằng cách đổi sangmoduleResolution: "bundler", thư viện có thể chạy được trong bundler nhưng hỏng khi dùng với Node thuần. - Lộ kiểu từ
devDependencies: nếuOrderdùng một kiểu của package nằm trongdevDependencies, file.d.tssẽ import package đó. Người dùng không cài package này nên gặp lỗi type. Kiểu nào xuất hiện trong API công khai thì package chứa nó phải nằm trongdependencieshoặcpeerDependencies. - Dựa vào
skipLibCheck: option này giúp bản build của bạn nhanh hơn, nhưng cũng che đi lỗi trong chính các file.d.tsbạn publish. Hãy test bằng một dự án mẫu tắtskipLibCheck. - Export enum hoặc namespace một cách tùy tiện:
const enumgây rắc rối cho người dùng bậtisolatedModules. Union string nhưCustomerTierthường đơn giản và an toàn hơn. - Đổi kiểu công khai trong bản patch: thêm một field bắt buộc vào
Orderlà breaking change, dù code JavaScript không đổi dòng nào. Hãy coi.d.tslà một phần của API khi đánh số phiên bản.
Khi nào không cần tự làm tất cả những bước trên? Nếu thư viện phải xuất cả ESM lẫn CommonJS, hãy dùng công cụ build chuyên dụng như tsup hoặc tsdown thay vì tự cấu hình hai lần tsc. Nếu code chỉ dùng nội bộ trong một monorepo, TypeScript project references thường là lựa chọn gọn hơn so với việc publish lên npm.
8. Kết luận
Bài viết đã xây dựng thư viện order-pricing từ đầu, với các bước chính sau:
- Bật
declaration,declarationMapvàisolatedDeclarationstrongtsconfig.jsonđể sinh.d.tsrõ ràng và ổn định. - Khai báo
exportstrongpackage.jsonvới điều kiệntypesđứng đầu, và giới hạn file publish bằngfiles. - Kiểm thử cả runtime lẫn type bằng Vitest,
expectTypeOfvà@ts-expect-error. - Viết
.d.tsthủ công cho code JavaScript cũ và nhớ đưa chúng vàodist.
Quy tắc quan trọng nhất là coi file .d.ts như hợp đồng với người dùng. Hãy kiểm tra gói thực tế bằng npm pack trước mỗi lần publish, vì nhiều lỗi cấu hình chỉ lộ ra ở phía người dùng chứ không xuất hiện trong dự án của bạn.