Bạn tạo project mới, tsc --init bật sẵn strict: true, và editor đỏ lòm hơn 200 lỗi. Deadline tới gần, bạn tắt strict cho yên — thế là TypeScript tụt xuống thành một cái autocomplete đắt tiền. Trớ trêu là phần lớn bug mà TypeScript hứa chặn giùm bạn nằm đúng trong đám flag vừa tắt.
Bài này không liệt kê mọi option trong tsconfig.json. Nó trả lời một câu hỏi: flag nào thực sự cứu code khỏi bug runtime, flag nào đáng bật thêm ngoài strict, và cách bật dần cho codebase cũ mà không phải sửa hết lỗi trong một buổi chiều. Ví dụ bên dưới đều đã chạy thật qua TypeScript 7.
1. strict: true không phải một flag, nó là cả một họ
strict: true là một “master switch”: bật nó tương đương bật cùng lúc chín flag con. Đừng học thuộc danh sách — cứ nhớ rằng bật strict là bật tất cả, rồi mới cân nhắc tắt lại từng cái nếu thật sự cần.
| Flag | Chặn bug gì |
|---|---|
strictNullChecks | null/undefined lọt vào mọi type mà không ai kiểm tra |
noImplicitAny | Tham số/biến không khai báo type âm thầm thành any |
strictFunctionTypes | Gán callback sai type tham số vào nhau |
strictBindCallApply | bind/call/apply sai số lượng hoặc type tham số |
strictPropertyInitialization | Property của class chưa được khởi tạo |
useUnknownInCatchVariables | Biến trong catch là unknown, buộc phải narrow |
noImplicitThis | this ngầm thành any trong function |
alwaysStrict | File output thiếu "use strict" |
strictBuiltinIteratorReturn | Đọc value của iterator trước khi check done |
Lưu ý phiên bản
Bài viết dùng TypeScript 7 — bản latest trên npm hiện tại. Cú pháp tsconfig không đổi so với TypeScript 5, nên mọi flag ở đây dùng được cho cả hai. Chỉ có danh sách flag mà strict bao gồm là lớn dần: strictBuiltinIteratorReturn chẳng hạn mới được thêm vào họ này ở bản 5.6.
1.1. strictNullChecks — flag quan trọng nhất
Nếu chỉ được bật một flag, hãy bật cái này. Không có strictNullChecks, null và undefined được coi là thành viên hợp lệ của mọi type — nên cái crash kinh điển nhất của JavaScript lọt thẳng qua TypeScript:
1
2
3
4
5
6
7
8
9
const users = [{ id: 1, name: "An" }];
function findUser(id) {
return users.find((u) => u.id === id);
}
const user = findUser(2); // không có id=2 trong array
console.log("Tìm thấy:", user);
console.log(user.name); // TypeScript (không strict) tin user luôn tồn tạiChạy thử: dòng đầu in Tìm thấy: undefined, dòng sau nổ đỏ TypeError: Cannot read properties of undefined (reading 'name'). Array.find() trả về T | undefined, nhưng không có strictNullChecks, TypeScript giấu nhẹm cái undefined đó đi.
Bật flag lên, TypeScript bắt bạn xử lý đúng chỗ đó ngay từ compile time:
1
2
3
4
5
6
7
8
9
10
11
12
13
function findUser(id: number) {
const users = [{ id: 1, name: "An" }];
return users.find((u) => u.id === id);
}
const user = findUser(2);
// Với strictNullChecks, user có type { id: number; name: string } | undefined
user.name;
// ❌ error TS18048: 'user' is possibly 'undefined'.
if (user) {
user.name; // ✅ đã narrow, TypeScript yên tâm
}1.2. noImplicitAny — chặn any lẻn vào
Một tham số không khai báo type sẽ âm thầm thành any, và any là chỗ mọi type checking dừng lại:
1
2
3
4
5
// Không có noImplicitAny: name âm thầm thành any
function greet(name) {
// ^ error TS7006: Parameter 'name' implicitly has an 'any' type.
return "Xin chào " + name.toUpperCase();
}Một khi name là any, name.toUpperCase() không còn được kiểm tra — truyền số vào cũng lọt, và bạn có một TypeError chờ sẵn ở runtime. Cách sửa đơn giản là khai báo type:
1
2
3
function greet(name: string) {
return "Xin chào " + name.toUpperCase();
}1.3. useUnknownInCatchVariables — biến catch là unknown
Ở JavaScript, bạn có thể throw bất cứ thứ gì: string, number, object — không nhất thiết là Error. Vì vậy TypeScript gán type unknown cho biến trong catch, và bắt bạn narrow trước khi chạm vào .message:
1
2
3
4
5
6
7
try {
JSON.parse("{bad json}");
} catch (err) {
// err có type unknown — chưa thể chạm vào .message
err.message;
// ❌ error TS18046: 'err' is of type 'unknown'.
}Nghe phiền, nhưng đây là điều tốt: nó phản ánh đúng sự thật rằng thứ bị throw chưa chắc là Error. Chỗ bạn narrow cũng chính là chỗ bạn phân loại được lỗi — lỗi validation của mình khác lỗi hệ thống bất ngờ:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ValidationError extends Error {}
function loadUser(raw) {
try {
const data = JSON.parse(raw);
if (typeof data.age !== "number") {
throw new ValidationError("age phải là number");
}
return data;
} catch (err) {
// err là unknown ở compile time — phải narrow trước khi chạm .message
if (err instanceof ValidationError) {
console.error("Dữ liệu sai:", err.message);
} else if (err instanceof Error) {
console.error("Lỗi khác:", err.message);
}
return null;
}
}
console.log("Kết quả:", loadUser('{"age":"30"}'));Chạy thử: in ra Dữ liệu sai: age phải là number rồi Kết quả: null. Việc tự định nghĩa các class Error riêng (như ValidationError ở trên) để instanceof phân loại lỗi là cả một chủ đề riêng; ở đây chỉ cần thấy unknown ép bạn narrow, và narrow chính là chỗ code trở nên an toàn.
2. Những flag nằm ngoài strict nhưng đáng bật ngay
strict là mặc định tốt, nhưng còn vài flag rất đáng bật mà nó không bao gồm — có lẽ vì chúng “ồn” hơn, bắt bạn sửa nhiều chỗ. Đổi lại, chúng chặn đúng loại bug hay lọt qua review nhất.
2.1. noUncheckedIndexedAccess — arr[0] có thể undefined
Đây là bug thật mà kể cả strict cũng bỏ sót. Mặc định, array[i] luôn có type T, dù index nằm ngoài array:
1
2
3
4
const scores = [90, 85, 70];
const third = scores[5]; // index vượt quá độ dài array
console.log("Điểm thứ 6:", third);
console.log(third.toFixed(1)); // TypeScript tin third là numberChạy thử: Điểm thứ 6: undefined, rồi TypeError: Cannot read properties of undefined (reading 'toFixed'). noUncheckedIndexedAccess sửa đúng chỗ này — mọi truy cập bằng index trả về T | undefined:
1
2
3
4
5
6
7
8
9
10
const scores = [90, 85, 70];
// Với noUncheckedIndexedAccess: scores[5] có type number | undefined
const third = scores[5];
third.toFixed(1);
// ❌ error TS18048: 'third' is possibly 'undefined'.
if (third !== undefined) {
third.toFixed(1); // ✅ đã kiểm tra
}Flag này ồn nhất trong nhóm: nó bắt bạn check cả những chỗ bạn “biết chắc” index tồn tại. Nhưng nó chặn đúng loại bug hay trốn khỏi mắt reviewer nhất — off-by-one, key không có trong Record, phần tử cuối của một array rỗng.
2.2. exactOptionalPropertyTypes — “thiếu” khác “undefined”
1
2
3
4
5
6
7
8
interface Options {
timeout?: number;
}
// Không có exactOptionalPropertyTypes: gán undefined vẫn lọt
const opts: Options = { timeout: undefined };
// ❌ error TS2375: Type '{ timeout: undefined; }' is not assignable
// to type 'Options' with 'exactOptionalPropertyTypes: true'.timeout?: number nghĩa là “có thể thiếu field này”, không phải “field này có thể là undefined“. Hai chuyện đó khác nhau thật sự: {} khác { timeout: undefined } khi bạn Object.keys(), khi serialize sang JSON, hay khi merge config. Flag này buộc bạn phân biệt rạch ròi thay vì trộn lẫn.
2.3. noImplicitOverride — bắt buộc gõ override
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
greet() {
return "hi";
}
}
class Sub extends Base {
// Ghi đè method của Base nhưng quên đánh dấu
greet() {
return "hello";
}
// ❌ error TS4114: This member must have an 'override' modifier
// because it overrides a member in the base class 'Base'.
}Cách sửa là thêm từ khoá override. Lợi ích thật nằm ở tương lai: khi ai đó đổi tên hoặc xoá greet() trong Base, mọi override greet() không còn khớp sẽ báo lỗi ngay — thay vì lặng lẽ biến thành một method mới toanh mà không ai để ý.
2.4. noFallthroughCasesInSwitch — quên break
1
2
3
4
5
6
7
8
9
10
11
function toLabel(n: number): string {
switch (n) {
case 0:
// ❌ error TS7029: Fallthrough case in switch.
const label = "zero"; // quên break/return → rơi xuống case 1
case 1:
return "one";
default:
return "many";
}
}Quên break là bug kinh điển của switch — case 0 chạy xong rơi thẳng xuống case 1. Flag này bắt đúng nó, trừ khi case trống hoàn toàn (kiểu gom nhiều case cùng xử lý thì vẫn hợp lệ).
2.5. noUnusedLocals — biến khai báo mà không dùng
1
2
3
4
5
function calcTotal(price: number): number {
const tax = price * 0.1; // tính xong... rồi quên cộng vào
// ❌ error TS6133: 'tax' is declared but its value is never read.
return price;
}Một biến khai báo rồi bỏ đó thường là dấu hiệu code viết dở dang hoặc logic sai — ở đây tax tính xong nhưng return lại quên cộng. Có noUnusedParameters song sinh làm điều tương tự cho tham số hàm.
3. target, lib, module và moduleResolution
Nhóm option này quyết định TypeScript hiểu code của bạn chạy ở đâu — trình duyệt, Node, hay qua bundler. Cấu hình sai nhóm này, lỗi hiện ra ở khâu import chứ không phải khâu type, và nó khó đoán hơn hẳn.
target: phiên bản JavaScript của output."esnext"cho code chạy qua bundler hoặc Node mới; hạ xuống"es2020"nếu cần hỗ trợ môi trường cũ.lib: bộ type có sẵn. App trình duyệt cần["DOM", "DOM.Iterable", "ESNext"]; code Node thuần bỏDOMđi, vìdocument/windowkhông tồn tại ở đó.module+moduleResolution: cách TypeScript sinh ra và tìm import — phần đáng nói nhất.
3.1. moduleResolution: bundler hay nodenext?
Chọn theo môi trường chạy thật. Nếu code của bạn đi qua một bundler (Vite, webpack, esbuild, Next.js):
1
2
3
4
5
6
7
8
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"noEmit": true
}
}Nếu bạn viết package publish lên npm, hoặc app Node chạy trực tiếp không qua bundler:
1
2
3
4
5
6
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext"
}
}Khác nhau chỗ nào:
"bundler": giao phần resolve cho bundler, nên bạn import không cần đuôi.jsvà dùng được mọi cú pháp import hiện đại."nodenext": bám đúng luật của Node — import file tương đối phải kèm đuôi (./api.js), và phân biệt ESM/CJS theo"type"trong package.json.
Có một luật dễ vấp: với node16/nodenext, module và moduleResolution phải khớp nhau. Đặt moduleResolution: "nodenext" mà quên đổi module, TypeScript sẽ báo lỗi cấu hình chứ không đoán giùm. Bản tsc --init của TypeScript 7 mặc định cặp nodenext — hợp cho package; nếu bạn viết app qua bundler, đổi sang cặp esnext + bundler.
3.2. verbatimModuleSyntax và import type
Khi bạn import một type rồi chỉ dùng nó ở vị trí type, TypeScript có thể xoá dòng import đó khỏi output. Nhưng ranh giới “cái này là type hay là value” đôi khi không rõ, và mỗi bundler đoán một kiểu. verbatimModuleSyntax chấm dứt việc đoán: import nào chỉ dùng làm type, bạn phải ghi rõ import type.
1
2
3
4
5
6
// User chỉ được dùng như type → phải là type-only import
import { User, makeUser } from "./types";
// ^ error TS1484: 'User' is a type and must be imported using
// a type-only import when 'verbatimModuleSyntax' is enabled.
const u: User = makeUser();Cách sửa là tách phần type ra:
1
2
3
4
5
6
import { makeUser, type User } from "./types";
// hoặc tách hẳn thành hai dòng:
// import type { User } from "./types";
// import { makeUser } from "./types";
const u: User = makeUser();tsc --init của TypeScript 7 bật sẵn verbatimModuleSyntax: true, nên đây là mặc định mới chứ không phải một tùy chọn kỳ lạ. Đổi lại chút gõ thêm, bạn được output nhất quán và tránh được lỗi “import bị xoá nhầm” khi một cái tên vừa là type vừa là value (như enum hay class).
4. paths và baseUrl: alias @/ và cái bẫy
Mệt mỏi vì import { api } from "../../../lib/api"? paths cho bạn đặt alias gọn gàng:
1
2
3
4
5
6
7
8
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}Giờ bạn viết import { api } from "@/lib/api" là được — về phía TypeScript. Và đây đúng là cái bẫy.
paths chỉ dạy cho type checker hiểu alias. Nó không đổi cách code chạy thật. Bundler, test runner và Node runtime không đọc paths trong tsconfig. Nên nếu bạn chỉ khai báo ở đây: tsc im lặng (nó hiểu @/), nhưng tới lúc chạy hoặc bundle, @/lib/api không resolve được và bạn nhận lỗi module not found.
View Mermaid diagram code
flowchart TD
Import["import từ @/lib/api"] --> TSC["tsc type-check"]
Import --> Runtime["bundler / test runner / Node"]
TSC -->|đọc paths trong tsconfig| A["Hiểu alias, không báo lỗi"]
Runtime -->|không đọc tsconfig| B["module not found lúc chạy"]
Runtime -.->|khai báo alias riêng| C["Chạy được"]
style A fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style C fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style B fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3Phải khai báo alias song song ở nơi code thật sự chạy:
- Vite:
resolve.alias - Vitest: kế thừa từ Vite config
- Jest:
moduleNameMapper - Node thuần: field
importstrong package.json, hoặctsconfig-paths
Lối tắt phổ biến: dùng plugin như vite-tsconfig-paths để đọc thẳng paths từ tsconfig, khỏi phải khai báo hai nơi rồi quên đồng bộ.
5. skipLibCheck, isolatedModules và types
5.1. skipLibCheck — nên bật
skipLibCheck: true bảo TypeScript bỏ qua việc type-check mọi file khai báo .d.ts — chủ yếu là các file đến từ thư viện trong node_modules. Nghe như đang tắt bớt an toàn, nhưng thực tế nên bật:
- Code
.tscủa bạn vẫn được check đầy đủ; chỉ những file khai báo.d.tsmới được bỏ qua. - Nó tránh cảnh hai package
@typeskhai báo xung đột nhau làm build của bạn đỏ, dù code bạn hoàn toàn đúng.
Tradeoff thật: nếu một thư viện ship type sai, bạn sẽ không được cảnh báo cho tới khi thật sự dùng tới nó. Đổi lại là build nhanh hơn và ổn định hơn — đủ đáng để tsc --init bật sẵn skipLibCheck: true.
5.2. isolatedModules
Bật flag này khi mỗi file được biên dịch riêng lẻ — trường hợp của Babel, esbuild, SWC, Vite. Những công cụ đó dịch từng file một, không nhìn toàn cục, nên vài cấu trúc hợp lệ với tsc lại khiến chúng bó tay: export { Foo } khi Foo là type (phải export type), const enum, v.v. isolatedModules bắt tsc từ chối đúng những cấu trúc đó, để bạn không viết ra code chạy được ở tsc nhưng vỡ ở bundler.
5.3. types — giới hạn @types nạp vào global
Mặc định, TypeScript tự nạp mọi package trong node_modules/@types vào global scope. Cài @types/node là process, Buffer bỗng xuất hiện khắp nơi — kể cả trong code trình duyệt lẽ ra không được có chúng. types khoá lại danh sách:
1
2
3
4
5
{
"compilerOptions": {
"types": ["node", "vitest/globals"]
}
}Chỉ những package liệt kê ở đây mới được nạp global; "types": [] thì không nạp gì cả. Đây là cách chặn @types của môi trường này lẫn sang môi trường khác.
6. Migrate codebase cũ sang strict mà không big-bang
Bật strict một phát trên codebase 50 nghìn dòng để nhận về 2000 lỗi là công thức chắc chắn bị revert. Cách làm dần dần và giữ được:
- Bật từ flag đáng giá nhất trước. Nếu bật cả
strictra quá nhiều lỗi, bắt đầu bằng riêngstrictNullChecks, ổn rồi mới mở rộng sang các flag còn lại. - File mới viết theo strict ngay từ đầu. Nợ cũ trả dần, nhưng đừng tạo thêm nợ mới.
- Chặn hồi quy bằng CI. Thêm một bước type-check bắt buộc để không ai push thêm lỗi type mới:
1
2
# Bước type-check trong CI — không sinh file, chỉ báo lỗi
tsc --noEmit- Đánh dấu chỗ chưa sửa bằng
// @ts-expect-error(kèm ghi chú lý do) ngay tại dòng lỗi, thay vì tắt cả một flag cho toàn dự án. Điểm hay: khi lỗi đó được sửa xong, chính@ts-expect-errorsẽ báo “không còn lỗi để expect” — nhắc bạn dọn nó đi, nên nợ kỹ thuật tự thu nhỏ dần.
Tóm lại: strict là mặc định bạn nên giữ, không phải thứ để tắt khi thấy màn hình đỏ. strictNullChecks và noUncheckedIndexedAccess chặn đúng loại bug hay rơi xuống production nhất, còn verbatimModuleSyntax cùng cặp module/moduleResolution quyết định code của bạn có import nổi hay không. Bài này cố tình không đụng tới cú pháp type — union, generic, utility type nằm ở bài TypeScript Types Nâng Cao Cho React. Mở tsconfig.json của dự án bạn lên, đối chiếu với những flag ở trên, và bật thêm noUncheckedIndexedAccess ngay hôm nay — nó sẽ tìm ra vài chỗ bạn không ngờ.