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
targetlà phiên bản JavaScript mà compiler xuất ra.ES2022phù 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.libkhai 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ọidocumenthaywindowtrong 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 trongnode_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êngnoUncheckedIndexedAccessthì 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: truecó nghĩa làtscchỉ 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òngjsxchỉ cần khi bạn dùng React.
Lưu ý: Không dùng
Bundlercho code chạy trực tiếp bằngnode 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:
modulevàmoduleResolutionđặt làNodeNext, đi kèm"type": "module"để compiler mô phỏng đúng runtime.strictcùng các cờnoUncheckedIndexedAccessvàexactOptionalPropertyTypesđể bắt lỗi sớm.verbatimModuleSyntaxvà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.jsontách riêng, đểdistchỉ 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.