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: true yêu cầu tsc sinh file .d.ts bên cạnh mỗi file .js. Đây là option cốt lõi của bài.
  • declarationMap: true sinh file .d.ts.map. Khi người dùng bấm “Go to Definition”, editor sẽ mở file .ts gố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ục src.
  • module và 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.ts mà không cần chạy type checker. API công khai cũng trở nên tường minh hơn.
  • exclude bỏ file test ra khỏi bản build, để test không lọt vào dist.

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 .js trong dist được Node hiểu là ESM.
  • Trong exports, điều kiện types phả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ếu default đứng trước, TypeScript sẽ không bao giờ đọc tới types.
  • main và types ở cấp gốc là phương án dự phòng cho công cụ cũ không đọc exports.
  • files giới hạn những gì được đưa lên npm. Thiếu field này, bạn dễ publish nhầm cả node_modules hoặ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 type bá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 .js sau khi build.
  • Đường dẫn import viết là ./types.js dù file nguồn là types.ts. Với NodeNext, đây là cách viết bắt buộc, vì Node sẽ tìm file .js khi chạy.
  • DISCOUNT_RATE khô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.
  • expectTypeOf khóa chữ ký của hàm. Nếu sau này ai đó đổi kiểu trả về thành string, test này sẽ fail.
  • @ts-expect-error khẳ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-error và các lỗi type chỉ được kiểm tra khi bạn chạy tsc --noEmit -p tsconfig.test.json (một config riêng có include file test) hoặc vitest --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 ý: tsc không copy file .js và .d.ts viết tay sang dist. Bạn có hai lựa chọn. Cách thứ nhất là bật allowJs: true để tsc copy legacy-tax.js sang dist, sau đó copy thêm legacy-tax.d.ts bằng một script, ví dụ "build": "tsc && cp src/legacy-tax.d.ts dist/". Cách thứ hai là bật allowJs kèm JSDoc và để tsc tự sinh .d.ts từ 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 sau default trong exports: TypeScript không đọc tới types, nên người dùng nhận any hoặc lỗi TS7016.
  • Quên đuôi .js khi import với NodeNext: tsc báo lỗi TS2835. Nếu bạn né lỗi bằng cách đổi sang moduleResolution: "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ếu Order dùng một kiểu của package nằm trong devDependencies, file .d.ts sẽ 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 trong dependencies hoặc peerDependencies.
  • 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.ts bạn publish. Hãy test bằng một dự án mẫu tắt skipLibCheck.
  • Export enum hoặc namespace một cách tùy tiện: const enum gây rắc rối cho người dùng bật isolatedModules. Union string như CustomerTier thườ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 Order là breaking change, dù code JavaScript không đổi dòng nào. Hãy coi .d.ts là 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, declarationMap và isolatedDeclarations trong tsconfig.json để sinh .d.ts rõ ràng và ổn định.
  • Khai báo exports trong package.json với điều kiện types đứng đầu, và giới hạn file publish bằng files.
  • Kiểm thử cả runtime lẫn type bằng Vitest, expectTypeOf và @ts-expect-error.
  • Viết .d.ts thủ công cho code JavaScript cũ và nhớ đưa chúng vào dist.

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.

typescriptnpmtype-declarationlibrarytsconfig