Strict mode trong TypeScript: bật những gì
1. Tổng quan
"strict": true là dòng quan trọng nhất trong tsconfig.json. Nhưng nhiều người bật nó mà không biết thực sự đã bật những gì. Bài này đi qua từng cờ mà strict kích hoạt, các lỗi thực tế mà mỗi cờ bắt được, và vài cờ nên bật thêm. Cuối bài có cách chuyển một dự án cũ sang strict mà không phải sửa hàng nghìn lỗi trong một lần.
2. strict là một nhóm cờ
strict không phải một kiểm tra riêng lẻ. Nó là công tắc tổng, bật cùng lúc một nhóm cờ:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"strict": true
}
}
Với TypeScript 5.x và 6.x, strict: true tương đương với:
{
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"strictBuiltinIteratorReturn": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"alwaysStrict": true
}
}
Có hai điều bạn cần nhớ:
- Mỗi khi TypeScript thêm một kiểm tra “strict” mới, ví dụ
useUnknownInCatchVariablesở 4.4 haystrictBuiltinIteratorReturnở 5.6, kiểm tra đó tự động được bật theostrict. Vì vậy, nâng cấp TypeScript có thể làm xuất hiện lỗi mới dù bạn không đụng vào code. - Bạn có thể bật
strictrồi tắt riêng một cờ con. Cờ ghi cụ thể luôn thắng cờ tổng.
TypeScript 6.0 đã đổi giá trị mặc định của strict thành true. Dù vậy, bạn vẫn nên ghi rõ cờ này trong tsconfig.json, để người đọc config không phải đoán.
Để thử các ví dụ bên dưới, bạn chỉ cần cài TypeScript rồi chạy tsc --noEmit:
npm install --save-dev typescript
npx tsc --noEmit
3. Các cờ quan trọng nhất
3.1. noImplicitAny
Khi TypeScript không suy luận được kiểu, nó sẽ ngầm gán any. Cờ này biến trường hợp đó thành lỗi.
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
error TS7006: Parameter 'items' implicitly has an 'any' type.
Nếu không có cờ này, items là any. Khi đó item.prise (gõ sai chính tả) vẫn compile bình thường. Cách sửa là khai báo kiểu rõ ràng:
interface OrderItem {
name: string;
price: number;
}
function calculateTotal(items: OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
Cờ này chỉ chặn any ngầm định. Bạn vẫn có thể viết any một cách tường minh nếu thực sự cần.
3.2. strictNullChecks
Đây là cờ đáng giá nhất. Khi tắt, null và undefined gán được cho mọi kiểu. Khi bật, chúng trở thành kiểu riêng và bạn phải xử lý rõ ràng.
interface Customer {
id: number;
email: string;
}
const customers: Customer[] = [{ id: 1, email: "an@example.com" }];
function getCustomerEmail(id: number): string {
const customer = customers.find((c) => c.id === id);
return customer.email;
}
error TS18048: 'customer' is possibly 'undefined'.
Array.prototype.find trả về Customer | undefined. Khi không bật cờ, TypeScript bỏ qua undefined, và lỗi Cannot read properties of undefined chỉ lộ ra lúc runtime. Cách sửa:
function getCustomerEmail(id: number): string | undefined {
const customer = customers.find((c) => c.id === id);
return customer?.email;
}
Hàm giờ trả về string | undefined. Kiểu trả về nói đúng sự thật, nên nơi gọi hàm cũng buộc phải xử lý trường hợp không tìm thấy.
Lưu ý: Đừng “sửa” lỗi bằng toán tử non-null
customer!.email. Toán tử!chỉ làm compiler im lặng, nó không kiểm tra gì cả. Chỉ dùng khi bạn chắc chắn giá trị tồn tại và có lý do mà compiler không thấy được.
3.3. strictPropertyInitialization
Cờ này yêu cầu mọi property của class phải được gán giá trị, hoặc ngay lúc khai báo, hoặc trong constructor. Nó cần strictNullChecks bật mới có hiệu lực.
class PaymentService {
apiKey: string;
retries = 3;
constructor(private readonly baseUrl: string) {}
}
error TS2564: Property 'apiKey' has no initializer and is not definitely assigned in the constructor.
Bạn có ba cách xử lý:
class PaymentService {
apiKey: string; // 1. gán trong constructor
webhookSecret?: string; // 2. khai báo là optional
client!: HttpClient; // 3. definite assignment: "tin tôi, sẽ được gán"
constructor(apiKey: string) {
this.apiKey = apiKey;
}
}
interface HttpClient {
post(url: string, body: unknown): Promise<unknown>;
}
Cách 3 (!) thường gặp với framework dùng dependency injection hoặc decorator, chẳng hạn Angular hay TypeORM. Ở đó property được gán sau khi constructor chạy xong. Ngoài những trường hợp như vậy, hãy ưu tiên cách 1 hoặc 2.
3.4. useUnknownInCatchVariables
Trong JavaScript, bạn có thể throw bất cứ thứ gì, không nhất thiết là Error. Cờ này đổi kiểu của biến trong catch từ any sang unknown.
async function chargeOrder(orderId: string): Promise<void> {
try {
await fetch(`/api/orders/${orderId}/charge`, { method: "POST" });
} catch (err) {
console.error(err.message);
}
}
error TS18046: 'err' is of type 'unknown'.
Để dùng err, bạn phải thu hẹp kiểu trước:
catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Charge failed: ${message}`);
}
3.5. strictFunctionTypes
Cờ này kiểm tra tham số của function type theo kiểu contravariant. Nói đơn giản: bạn không thể truyền một hàm chỉ xử lý được kiểu hẹp vào chỗ cần hàm xử lý kiểu rộng.
interface Animal { name: string; }
interface Dog extends Animal { bark(): void; }
const handleDog = (dog: Dog) => dog.bark();
const handleAnimal: (animal: Animal) => void = handleDog;
error TS2322: Type '(dog: Dog) => void' is not assignable to type '(animal: Animal) => void'.
Nếu phép gán trên được chấp nhận, gọi handleAnimal({ name: "Mèo" }) sẽ crash vì đối tượng truyền vào không có bark. Có một ngoại lệ cần biết: cờ này không áp dụng cho method khai báo theo cú pháp method(x: T): void trong interface. Đó là lựa chọn có chủ đích của TypeScript để giữ tương thích với nhiều API sẵn có.
3.6. Ba cờ còn lại
Ba cờ này ít khi gây lỗi ở code mới, nhưng vẫn đáng biết:
function formatPrice(amount: number, currency: string): string {
return `${amount.toFixed(2)} ${currency}`;
}
formatPrice.call(null, "100", "VND"); // strictBindCallApply
function onClick() {
console.log(this.id); // noImplicitThis
}
error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
error TS2683: 'this' implicitly has type 'any' because it does not have a type annotation.
strictBindCallApplykiểm tra tham số khi bạn gọicall,applyvàbind.noImplicitThisbáo lỗi khithiscó kiểuanyngầm định. Bạn sửa bằng cách khai báo tham số giảfunction onClick(this: HTMLButtonElement).alwaysStrictphát"use strict"vào mỗi file output và parse code ở strict mode của JavaScript. Với ES module, strict mode đã là mặc định.
4. Những cờ strict không bật
Nhiều người nghĩ strict: true là mức nghiêm ngặt nhất. Không phải vậy. Các cờ sau nằm ngoài nhóm strict, nhưng rất đáng bật cho dự án mới:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
Trong số này, noUncheckedIndexedAccess là cờ quan trọng nhất. Mặc định, TypeScript coi truy cập mảng theo index hay truy cập object theo key động là luôn có giá trị:
const stockBySku: Record<string, number> = { "SKU-1": 10 };
const quantity = stockBySku["SKU-999"];
console.log(quantity.toFixed(0));
Không có cờ này, quantity có kiểu number và đoạn code trên crash ở runtime. Khi bật cờ, quantity có kiểu number | undefined:
error TS18048: 'quantity' is possibly 'undefined'.
Các cờ còn lại:
exactOptionalPropertyTypes:discount?: numbernghĩa là “có thể không có key”, chứ không cho phép gán tường minhdiscount: undefined.noImplicitOverride: method ghi đè lớp cha phải có từ khóaoverride. Nhờ đó, khi lớp cha đổi tên method, lớp con sẽ báo lỗi thay vì âm thầm tạo ra một method mới.noImplicitReturns: mọi nhánh của hàm đều phảireturn.noFallthroughCasesInSwitch: chặn trường hợp quênbreaktrongswitch.
Lưu ý:
noUncheckedIndexedAccesskhá “ồn”. Vòng lặpfor (let i = 0; i < arr.length; i++)cũng cho raarr[i]có kiểuT | undefined. Hãy chuyển sangfor...ofhoặcarr.forEach. Với dự án lớn có sẵn, nên bật cờ này sau cùng.
5. Kiểm tra hành vi bằng test
Type check chạy lúc compile, nhưng bạn vẫn nên có test cho cả hành vi runtime lẫn kiểu trả về. Vitest hỗ trợ cả hai:
npm install --save-dev vitest
// customer.ts
export interface Customer { id: number; email: string; }
const customers: Customer[] = [{ id: 1, email: "an@example.com" }];
export function getCustomerEmail(id: number): string | undefined {
return customers.find((c) => c.id === id)?.email;
}
// customer.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { getCustomerEmail } from "./customer";
describe("getCustomerEmail", () => {
it("trả về email khi tìm thấy customer", () => {
expect(getCustomerEmail(1)).toBe("an@example.com");
});
it("trả về undefined khi không tìm thấy", () => {
expect(getCustomerEmail(42)).toBeUndefined();
});
it("kiểu trả về bao gồm undefined", () => {
expectTypeOf(getCustomerEmail).returns.toEqualTypeOf<string | undefined>();
});
});
Test thứ ba là một type test. Giả sử sau này có người đổi chữ ký hàm thành : string và dùng ! để ép kiểu. Khi đó vitest --typecheck (hoặc tsc --noEmit trên file test) sẽ báo lỗi, vì kiểu trả về không còn khớp với string | undefined.
npx vitest run --typecheck
✓ customer.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)
6. Chuyển dự án cũ sang strict
Bật strict: true trên một codebase lớn có thể sinh ra hàng nghìn lỗi. Cách làm thực tế là đi từng bước.
Bước 1: bật strict, nhưng tạm tắt những cờ ồn nhất.
{
"compilerOptions": {
"strict": true,
"strictNullChecks": false,
"strictPropertyInitialization": false,
"useUnknownInCatchVariables": false
}
}
noImplicitAny thường dễ sửa nhất, nên xử lý nó trước. Mỗi lần sửa xong một cờ, bạn xóa dòng tắt cờ đó đi. Code mới viết ra sẽ tự động chịu mọi kiểm tra đang bật.
Bước 2: đếm lỗi theo cờ để ước lượng công sức.
npx tsc --noEmit --strictNullChecks true | grep -c "error TS"
Bước 3: với strictNullChecks, sửa theo từng thư mục. Đánh dấu tạm những chỗ chưa kịp sửa bằng // @ts-expect-error, kèm ghi chú lý do. Đừng dùng @ts-ignore. Lý do: @ts-expect-error sẽ tự báo lỗi khi dòng bên dưới không còn lỗi nữa, nhờ vậy bạn không bị sót comment thừa.
// @ts-expect-error TODO(strict): customer có thể undefined, xử lý ở PR #482
const email: string = findCustomer(id).email;
Bước 4: chặn hồi quy trong CI. Thêm tsc --noEmit vào pipeline để không ai merge được code làm phát sinh lỗi type mới.
Lưu ý: Đừng dùng
skipLibCheckhayanyhàng loạt như một cách “bật strict cho nhanh”.skipLibCheck: truechỉ bỏ qua việc kiểm tra file.d.ts, nên có thể giữ. Còn rảianykhắp nơi sẽ làm mất toàn bộ lợi ích của việc chuyển đổi.
7. Kết luận
strict: true bật chín kiểm tra. Hai kiểm tra mang lại giá trị lớn nhất là noImplicitAny và strictNullChecks, vì chúng chặn phần lớn lỗi undefined is not a function và Cannot read properties of undefined ngay từ lúc compile. Với dự án mới, bạn nên bật strict cùng noUncheckedIndexedAccess, noImplicitOverride và exactOptionalPropertyTypes ngay từ đầu. Với dự án cũ, hãy bật strict rồi tắt tạm từng cờ con, sửa dần theo thứ tự từ dễ đến khó, và dùng CI để giữ cho số lỗi chỉ giảm chứ không tăng.