✦

Build thư viện TypeScript — tsup, ESM/CJS và exports map

•Phong Hy

Hồi trước mình viết một package tiện ích nho nhỏ, publish lên npm cho vui. Hai ngày sau có người mở issue: import vào Next.js thì chạy ngon, còn require() trong một script Node cũ thì văng ERR_REQUIRE_ESM. Code chẳng có gì sai. Chỗ sai nằm ở package.json — đúng hơn là ở mấy dòng mình không khai báo.

Lần đó mình mới ngồi dò lại hết đường đi từ source TypeScript tới lúc người khác gõ import. Bài này kể lại, kèm mấy bẫy mình từng dính.

tsc không sinh ra code để chạy

Sai lầm đầu tiên: tin rằng tsc là đủ để build thư viện. tsc lo phần type, nhưng nó không bundle, không đổi đuôi file, và không quan tâm người ta import theo kiểu nào. Chạy tsc xong bạn được một đống .js + .d.ts nằm rải rác theo cấu trúc thư mục, còn chuyện ESM hay CJS thì để người dùng tự lo.

Với thư viện nhỏ, mình chốt dùng tsup — nó bọc esbuild, cấu hình gọn, chạy một lệnh là có sẵn cả .js lẫn file type.

// src/index.ts
export type Money = { amount: number; currency: 'VND' | 'USD' };

export function formatMoney(m: Money, locale = 'vi-VN'): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: m.currency,
  }).format(m.amount);
}
// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  clean: true,
  sourcemap: true,
  target: 'node20',
});

Chạy npx tsup là ra dist/index.js (ESM), dist/index.cjs (CJS), kèm index.d.ts và index.d.cts. Phần type quan trọng không kém phần code — mình sẽ nói ở dưới.

exports map là chỗ quyết định ai import được

Đây là nguyên nhân cái issue kia:

{
  "name": "money-utils",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
      "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
    }
  }
}

exports hoạt động như một bảng định tuyến. Node gặp import thì đi nhánh import, gặp require thì đi nhánh require. Điều kiện nào đứng trước thì được xét trước — nên types phải nằm trên default, đặt ngược lại là TypeScript không thấy type dù file có đó.

Bốn bẫy mình từng sập

Thiếu file type cho CJS. Nhánh require trỏ vào .d.ts (vốn là ESM) sẽ ăn lỗi TS1479 kiểu "types resolved to ESM". Cách chữa đơn giản nhất là để tsup sinh luôn .d.cts, mỗi định dạng một file type riêng.

moduleResolution của người dùng. exports chỉ được đọc khi dự án họ xài node16 hoặc nodenext. Ai còn để node cũ thì Node/TS bỏ qua exports và quay về main, module — lúc đó thứ tự import lệch hẳn so với bạn tưởng.

sideEffects: false khai báo ẩu. Cờ này giúp bundler tree-shake mạnh tay, nhưng nếu package có file chạy side effect lúc import (đăng ký polyfill, inject CSS) thì bundler cắt luôn và app chết lúc runtime, rất khó lần.

Quên files. Không khai báo files trong package.json là npm publish đẩy cả src, cả test, cả tsconfig lên registry. Người dùng tải nặng hơn mà chẳng được gì.

Kiểm tra trước khi bấm publish

Ba lệnh mình chạy mỗi lần release, mất chưa tới một phút:

  • npm pack --dry-run — xem đúng những file nào sẽ lên registry
  • npx publint — soi exports map có hợp lệ không
  • npx @arethetypeswrong/cli --pack . — kiểm tra type resolve đúng cho từng chế độ (ESM, CJS, bundler)

Thêm một bước nữa mà nhiều người bỏ: tạo dự án trống, npm i ../ten-package, rồi thử cả import lẫn require trong hai file khác nhau. Test trên máy mình đủ kiểu vẫn có thể lọt, vì "máy mình chạy được" không đồng nghĩa "máy người khác chạy được".

Chốt

Build thư viện TypeScript không khó, khó ở chỗ mình phải nghĩ cho cả những người dùng không kiểm soát được: runtime nào, bundler nào, tsconfig kiểu gì. exports map chính là hợp đồng công khai của package — viết sai một dòng thì tới lúc có issue mới biết.

Câu hỏi cho bạn: package gần nhất bạn publish có file .d.cts chưa, hay vẫn chỉ có mỗi .d.ts?

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích ngắn
ESM / CJS Hai hệ module của JavaScript: import/export so với require/module.exports
exports map Bảng định tuyến trong package.json, quyết định file nào được trả cho từng điều kiện import
Conditional exports Nhánh import / require / types trong exports, xét theo thứ tự khai báo
.d.ts / .d.cts File khai báo type; .d.cts dành cho nhánh CommonJS để tránh lỗi lệch hệ module
moduleResolution Cách TypeScript tìm module; chỉ node16/nodenext mới đọc exports
sideEffects Cờ báo cho bundler biết file nào an toàn để cắt khi tree-shake
Tree-shaking Bundler loại bỏ code không dùng tới khi đóng gói
publint / attw Công cụ kiểm tra exports map và type resolution trước khi publish
✦