Cấu hình tsconfig cho dự án TypeScript mới

1. Tổng quan

File tsconfig.json quyết định cách TypeScript kiểm tra kiểu và biên dịch code. Nếu cấu hình sai, bạn có thể gặp lỗi import lúc chạy, hoặc compiler bỏ sót nhiều lỗi kiểu.

Bài này dựng một tsconfig.json cho dự án Node.js mới từ đầu. Chúng ta sẽ bật các cờ kiểm tra chặt, chọn đúng hệ module, rồi tách cấu hình build khỏi cấu hình test. Đọc xong, bạn sẽ có một bộ cấu hình dùng được ngay và hiểu từng dòng trong đó.

2. Khởi tạo dự án

Tạo thư mục và cài TypeScript làm dev dependency:

mkdir order-service && cd order-service
npm init -y
npm install --save-dev typescript @types/node
npx tsc --init

Lệnh tsc --init sinh ra một tsconfig.json có rất nhiều dòng comment. File này hữu ích để tra cứu, nhưng khó bảo trì. Chúng ta sẽ thay nó bằng một bản ngắn gọn, ghi rõ từng tùy chọn.

Tiếp theo, khai báo dự án dùng ES Module trong package.json:

{
  "name": "order-service",
  "type": "module",
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/index.js"
  }
}

Dòng "type": "module" báo cho Node.js rằng các file .js là ESM. Lựa chọn này ảnh hưởng trực tiếp tới cách TypeScript phân giải module, như bạn sẽ thấy ở mục 3.

3. Cấu hình tối thiểu cho Node.js

Đây là tsconfig.json nền:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "types": ["node"],

    "rootDir": ".",
    "outDir": "dist",
    "sourceMap": true,
    "declaration": false,

    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src", "tests"]
}

3.1. target, lib và types

  • target là phiên bản JavaScript mà compiler xuất ra. ES2022 phù hợp với Node.js 18 trở lên. Với target này, async/await, optional chaining và class field được giữ nguyên, không bị hạ cấp.
  • lib khai báo những API có sẵn lúc chạy. Không có "DOM" nên bạn sẽ không vô tình gọi document hay window trong code server.
  • types: ["node"] chỉ nạp kiểu của Node.js. Nếu không khai báo, TypeScript tự nạp mọi gói trong node_modules/@types. Điều này đôi khi kéo theo kiểu toàn cục không mong muốn.

3.2. module và moduleResolution

Với dự án chạy trực tiếp trên Node.js, hãy dùng NodeNext cho cả hai tùy chọn. Ở chế độ này, TypeScript mô phỏng đúng cách Node.js phân giải import. Nó đọc trường "type" và "exports" trong package.json, và bắt buộc import tương đối phải có đuôi file.

Giả sử bạn có hai file:

// src/order.ts
export interface Order {
  id: string;
  items: string[];
  note?: string;
}

export function totalItems(order: Order): number {
  return order.items.length;
}
// src/index.ts
import { totalItems } from "./order";

console.log(totalItems({ id: "A1", items: ["pho", "tra-da"] }));

Chạy npx tsc --noEmit sẽ báo lỗi:

src/index.ts:1:28 - error TS2835: Relative import paths need explicit file extensions
in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
Did you mean './order.js'?

Bạn cần sửa thành import { totalItems } from "./order.js";. Phần đuôi là .js chứ không phải .ts, vì lúc chạy Node.js đọc file đã biên dịch. Quy tắc này thoạt nhìn lạ, nhưng nó giúp bạn tránh lỗi ERR_MODULE_NOT_FOUND khi deploy.

3.3. rootDir và outDir

outDir là nơi chứa file .js sau khi build. rootDir quyết định cấu trúc thư mục bên trong outDir. Ở file nền, rootDir là "." vì include có cả src lẫn tests. Ở mục 6, file build sẽ đổi rootDir thành src, để dist/index.js nằm đúng chỗ thay vì ở dist/src/index.js.

4. Strict mode và các cờ kiểm tra bổ sung

Cờ strict: true bật một nhóm kiểm tra, trong đó có strictNullChecks, noImplicitAny và strictFunctionTypes. Với dự án mới, bạn nên luôn bật cờ này. Bật nó về sau cho một codebase lớn sẽ tốn công hơn nhiều.

Ngoài strict, nên thêm các cờ sau:

{
  "compilerOptions": {
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

Bạn gộp các dòng này vào khối compilerOptions của file nền. Hai cờ đầu đáng được giải thích kỹ hơn.

4.1. noUncheckedIndexedAccess

Mặc định, TypeScript coi phần tử lấy theo index luôn tồn tại. Thực tế thì không phải vậy:

import type { Order } from "./order.js";

const ordersById: Record<string, Order> = {};

const order = ordersById["A1"];
console.log(order.id);

Khi bật cờ này, kiểu của order là Order | undefined, và compiler báo:

error TS18048: 'order' is possibly 'undefined'.

Bạn buộc phải kiểm tra trước khi dùng: if (order) { console.log(order.id); }. Nếu tắt cờ, đoạn code trên vẫn biên dịch được nhưng sẽ ném TypeError lúc chạy.

4.2. exactOptionalPropertyTypes

Cờ này phân biệt “không có thuộc tính” với “thuộc tính có giá trị undefined”:

import type { Order } from "./order.js";

const order: Order = { id: "A2", items: [], note: undefined };
error TS2375: Type '{ id: string; items: never[]; note: undefined; }' is not assignable
to type 'Order' with 'exactOptionalPropertyTypes: true'.

Sự phân biệt này quan trọng khi bạn dùng Object.keys, toán tử in hoặc serialize sang JSON. Nếu bạn thực sự muốn cho phép undefined, hãy khai báo rõ: note?: string | undefined.

Lưu ý: exactOptionalPropertyTypes đôi khi xung đột với kiểu của thư viện bên thứ ba, nhất là các thư viện xử lý form và ORM. Nếu cờ này gây quá nhiều lỗi ngoài tầm kiểm soát của bạn, hãy tắt nó. Riêng noUncheckedIndexedAccess thì nên giữ.

5. Tương tác giữa các module

Thêm hai cờ sau vào compilerOptions:

{
  "compilerOptions": {
    "verbatimModuleSyntax": true,
    "isolatedModules": true
  }
}

verbatimModuleSyntax có một quy tắc đơn giản: import nào không có chữ type thì được giữ nguyên trong output, còn import có type thì bị xóa. Nhờ đó, kết quả biên dịch dễ đoán. Đổi lại, bạn phải đánh dấu rõ các import chỉ dùng cho kiểu:

// Sai: Order chỉ là interface
import { Order } from "./order.js";
error TS1484: 'Order' is a type and must be imported using a type-only import
when 'verbatimModuleSyntax' is enabled.
// Đúng
import type { Order } from "./order.js";
import { totalItems } from "./order.js";

isolatedModules đảm bảo mỗi file có thể được biên dịch riêng lẻ. Các công cụ như esbuild, swc hay Vitest đều xử lý từng file một, nên cờ này giúp bạn bắt lỗi trước khi chúng gặp sự cố.

Bạn có thể đã thấy esModuleInterop trong nhiều hướng dẫn cũ. Với module: NodeNext, cờ này được bật mặc định, nên bạn không cần khai báo thêm.

6. Tách cấu hình typecheck và build

File tsconfig.json gốc phục vụ editor và lệnh typecheck. Nó bao gồm cả tests. Khi build, bạn không muốn đưa file test vào dist. Vì vậy, hãy tạo thêm tsconfig.build.json:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "noEmit": false
  },
  "include": ["src"],
  "exclude": ["**/*.test.ts"]
}

extends kế thừa toàn bộ compilerOptions từ file gốc. Trong file con, include và exclude sẽ thay thế hoàn toàn giá trị trong file cha chứ không gộp lại. Chạy lệnh build:

npm run build
ls dist
index.js  index.js.map  order.js  order.js.map

Thư mục dist chỉ chứa code ứng dụng, đặt ngay ở gốc nhờ rootDir: "src".

6.1. Viết test với Vitest

Cài Vitest:

npm install --save-dev vitest

Thêm "test": "vitest run" vào scripts, rồi viết test cho hàm totalItems:

// tests/order.test.ts
import { describe, expect, it } from "vitest";
import type { Order } from "../src/order.js";
import { totalItems } from "../src/order.js";

describe("totalItems", () => {
  it("đếm đúng số món trong đơn", () => {
    const order: Order = { id: "A1", items: ["pho", "tra-da", "quay"] };
    expect(totalItems(order)).toBe(3);
  });

  it("trả về 0 với đơn rỗng", () => {
    expect(totalItems({ id: "A2", items: [] })).toBe(0);
  });
});
npm run typecheck && npm test
 ✓ tests/order.test.ts (2 tests)
 Test Files  1 passed (1)
      Tests  2 passed (2)

Vitest tự loại bỏ kiểu khi chạy mà không kiểm tra chúng. Vì vậy, hãy luôn chạy typecheck trong CI. Nếu không, một test có lỗi kiểu vẫn có thể “pass”.

7. Biến thể cho dự án dùng bundler

Nếu code được đóng gói bởi Vite, esbuild hoặc webpack thay vì chạy trực tiếp trên Node.js, hãy đổi phần module như sau:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noEmit": true,
    "jsx": "react-jsx"
  }
}
  • moduleResolution: "Bundler" cho phép import không cần đuôi file, vì bundler tự phân giải.
  • noEmit: true có nghĩa là tsc chỉ kiểm tra kiểu, còn bundler lo việc xuất file.
  • Thêm "DOM" vì code chạy trên trình duyệt. Dòng jsx chỉ cần khi bạn dùng React.

Lưu ý: Không dùng Bundler cho code chạy trực tiếp bằng node dist/index.js. Compiler sẽ chấp nhận import thiếu đuôi .js, nhưng Node.js sẽ báo lỗi lúc chạy.

8. Lỗi hay gặp

Dùng paths để tạo alias rồi build bằng tsc. Giả sử bạn khai báo "paths": { "@/*": ["src/*"] }. Editor hiểu alias này, nhưng tsc không viết lại đường dẫn trong output, nên node sẽ không tìm thấy @/order.js. Với dự án Node.js, hãy dùng subpath imports trong package.json:

{
  "imports": {
    "#src/*": "./dist/*"
  }
}

Sau đó, bạn import bằng import { totalItems } from "#src/order.js";. Ở chế độ NodeNext, TypeScript đọc trường imports và ánh xạ ngược từ dist về src, nên cả editor lẫn runtime đều phân giải đúng.

Lạm dụng skipLibCheck. Cờ này chỉ bỏ qua việc kiểm tra file .d.ts, giúp build nhanh hơn và tránh lỗi từ kiểu của thư viện. Nó không làm code của bạn kém an toàn đi. Dù vậy, nếu bạn tự viết file .d.ts, hãy nhớ rằng lỗi trong chính các file đó cũng sẽ bị bỏ qua.

Quên include. Khi không có include, TypeScript quét toàn bộ thư mục, kể cả dist. Kết quả thường là lỗi TS5055: Cannot write file ... because it would overwrite input file.

Trộn CommonJS và ESM. Nếu package.json không có "type": "module" mà bạn vẫn dùng NodeNext, các file .ts sẽ được biên dịch thành CommonJS. Khi đó, import một gói chỉ hỗ trợ ESM sẽ gây lỗi. Hãy chọn một hướng ngay từ đầu. Với dự án mới, nên chọn ESM.

Bật strict rồi rải any khắp nơi. Làm vậy thì strict mode mất tác dụng. Khi chưa biết kiểu của dữ liệu, hãy dùng unknown rồi thu hẹp kiểu bằng câu lệnh kiểm tra.

9. Kết luận

Bài viết đã dựng một tsconfig.json cho dự án Node.js mới gồm các phần sau:

  • module và moduleResolution đặt là NodeNext, đi kèm "type": "module" để compiler mô phỏng đúng runtime.
  • strict cùng các cờ noUncheckedIndexedAccess và exactOptionalPropertyTypes để bắt lỗi sớm.
  • verbatimModuleSyntax và isolatedModules để output dễ đoán và tương thích với các công cụ biên dịch nhanh.
  • File tsconfig.build.json tách riêng, để dist chỉ chứa code ứng dụng.

Nếu dự án dùng bundler, bạn chỉ cần đổi sang moduleResolution: "Bundler" và noEmit: true. Các cờ kiểm tra chặt vẫn giữ nguyên. Cuối cùng, hãy luôn chạy tsc --noEmit trong CI, vì phần lớn công cụ chạy test và bundler không kiểm tra kiểu thay bạn.

typescripttsconfignodejscau-hinhvitest