Gõ code mà autocomplete quay vòng vài giây, tsc chạy cả phút, CI thì đợi mãi không xong — dự án chưa lớn lắm mà đã ì ạch. Phần lớn thời gian đó đến từ vài cấu hình sai và vài kiểu type đắt đỏ mà bạn không biết là đắt.
Bài này chỉ nói về build-time và DX performance: thời gian tsc chạy và độ trễ của editor. Nó không đụng tới performance lúc app chạy trên trình duyệt — render, script loading — những thứ đó nằm ở Critical Rendering Path và async vs defer. Ở đây, “chậm” nghĩa là bạn ngồi chờ compiler, không phải người dùng ngồi chờ trang.
1. Đo trước, đừng đoán
Sai lầm phổ biến nhất là bật đại vài flag “tối ưu” rồi hy vọng. TypeScript có sẵn công cụ chỉ đúng chỗ chậm — dùng nó trước, rồi mới sửa.
1.1. tsc –extendedDiagnostics
Thêm cờ --extendedDiagnostics vào lệnh build, tsc in ra bảng chi phí thay vì chỉ báo lỗi:
1
tsc --noEmit --extendedDiagnostics1
2
3
4
5
6
7
8
Files: 94 # số file phải đọc (kể cả .d.ts của thư viện)
Lines: 59534
Types: 35761 # số type phải tạo ra
Instantiations: 34593 # số lần "gọi" generic — hay phình to nhất
Memory used: 66257K
Check time: 0.251s # thời gian type-check — thủ phạm thường nằm đây
Emit time: 0.000s
Total time: 0.297sHai dòng đáng nhìn nhất là Instantiations và Check time. Check time cao mà Emit time thấp nghĩa là bạn tốn thời gian ở khâu type-check chứ không phải sinh file — tối ưu type-level (mục 4, 5) sẽ ăn tiền. Instantiations phình lên hàng triệu là dấu hiệu một generic nào đó đang bị “gọi” quá nhiều lần. Còn --diagnostics (không có extended) chỉ in bản rút gọn, thiếu đúng hai dòng quan trọng này.
1.2. generateTrace và analyze-trace: tìm đúng file, đúng type
--extendedDiagnostics cho biết có chậm, nhưng không cho biết ở đâu. Cặp --generateTrace cộng với package @typescript/analyze-trace mới chỉ ra đúng file và type gây tốn:
1
2
3
4
5
# 1. Sinh trace trong thư mục ./trace khi build
tsc --noEmit --generateTrace trace
# 2. Phân tích để tìm "hot spot"
npx @typescript/analyze-trace traceanalyze-trace đọc đống JSON trong ./trace và in ra danh sách nơi tốn thời gian nhất:
1
2
3
4
Hot Spots
└─ Check file .../node_modules/.../lib.dom.d.ts (266ms)
No duplicate packages foundTrên dự án thật, “hot spot” thường không phải lib.dom.d.ts mà là chính một file type của bạn — một alias generic lồng nhau, hay một union khổng lồ. Đó chính là chỗ đáng viết lại. Dòng No duplicate packages found cũng đáng để ý: nếu nó liệt kê package trùng, bạn đang nạp hai bản @types của cùng một thư viện — dọn đi là type giảm ngay.
Lưu ý
--generateTrace sinh ra file trace.json mở được bằng chrome://tracing hoặc edge://tracing nếu bạn muốn xem trực quan. Nhưng với đa số trường hợp, output text của analyze-trace đã đủ để tìm thủ phạm.
1.3. Đo độ trễ của editor
tsc chậm làm CI lâu; TS Server chậm làm bạn khó chịu cả ngày. Đây là hai tiến trình khác nhau, và editor có log riêng. Trong VS Code, mở Command Palette rồi chạy TypeScript: Open TS Server Log — log này ghi lại từng request và thời gian phản hồi, để bạn biết autocomplete quay vòng là vì file nào. Chỉ số nhanh khác: TypeScript: Restart TS Server, nếu editor mượt hẳn lên sau khi restart thì vấn đề là bộ nhớ tích tụ, không phải type của bạn.
2. incremental, project references và phạm vi build
Sau khi biết chậm ở đâu, đòn bẩy lớn nhất là đừng check lại thứ không đổi. Ba cơ chế dưới đây đều xoay quanh ý đó.
2.1. incremental — chỉ check lại phần thay đổi
Mặc định tsc check lại từ đầu mỗi lần chạy. Bật incremental, nó lưu đồ thị dự án vào một file .tsbuildinfo và lần sau chỉ check phần đổi:
1
2
3
4
5
6
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./node_modules/.cache/tsbuildinfo"
}
}Lần build đầu vẫn tốn như cũ, nhưng những lần sau nhanh hơn hẳn vì tsc bỏ qua phần không đụng tới. tsBuildInfoFile cho bạn đặt file cache ra ngoài outDir (ví dụ vào node_modules/.cache) để nó không lẫn vào output — nhớ cho nó vào .gitignore, đây là file máy sinh, không commit.
2.2. project references cho monorepo
incremental tăng tốc trong phạm vi một project. Với monorepo nhiều package, project references cho phép mỗi package build độc lập và cache riêng. Package con bật composite (tự động kéo theo incremental):
1
2
3
4
5
6
7
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "./dist"
}
}Package cha khai báo nó phụ thuộc vào những package nào:
1
2
3
{
"references": [{ "path": "../core" }, { "path": "../utils" }]
}Rồi build bằng tsc -b (viết tắt của --build) thay vì tsc thường:
1
tsc -btsc -b đọc đồ thị references, build các package theo đúng thứ tự phụ thuộc, và bỏ qua package nào chưa đổi kể từ lần build trước. Sửa một dòng trong core, chỉ core và app (thứ phụ thuộc vào core) được build lại — còn utils không đổi nên được bỏ qua, chứ không phải cả monorepo build lại từ đầu.
View Mermaid diagram code
flowchart TD
App["app"] --> Core["core"]
App --> Utils["utils"]
Core --> Utils
Change["Sửa core"] -.-> Rebuild["tsc -b: build lại core + app, bỏ qua utils"]
style Core fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style Utils fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style Rebuild fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff2.3. include và exclude: đừng để tsc quét rác
tsc chậm oan nếu nó đi quét những thư mục lẽ ra phải bỏ qua. Khoanh vùng đúng chỗ code nguồn:
1
2
3
4
{
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}include: ["src"] bảo tsc chỉ nhìn vào src. node_modules và dist mặc định đã bị loại, nhưng nếu bạn để include quá rộng (ví dụ ["."]), tsc có thể lôi cả file build cũ trong dist hay script config lẻ tẻ ở gốc vào type-check — vừa chậm vừa sinh lỗi ở nơi bạn không quan tâm. Cấu hình chi tiết hơn về module, moduleResolution và các strict flag nằm ở bài tsconfig.json: strict flags nào thực sự cứu code của bạn.
3. Giảm tải cho type-checker
Hai option dưới đây không đổi logic code, chỉ cắt bớt lượng type mà tsc phải xử lý.
3.1. skipLibCheck — bỏ qua type-check file .d.ts
skipLibCheck: true bảo tsc không type-check nội dung các file khai báo .d.ts — phần lớn đến từ thư viện trong node_modules:
1
2
3
4
5
{
"compilerOptions": {
"skipLibCheck": true
}
}Code .ts của bạn vẫn được check đầy đủ; chỉ các file .d.ts mới được bỏ qua. Lợi ích kép: build nhanh hơn, và tránh cảnh hai package @types khai báo xung đột nhau làm build đỏ dù code bạn hoàn toàn đúng.
Tradeoff thật, đừng bỏ qua: nếu một thư viện ship type sai trong .d.ts của nó, bạn sẽ không được cảnh báo cho tới khi thật sự dùng tới. Đổi lại tốc độ và sự ổn định, đa số dự án chấp nhận đánh đổi này — tsc --init của TypeScript 7 cũng bật sẵn.
3.2. types — đừng nạp toàn bộ @types
Mặc định TypeScript nạp mọi package trong node_modules/@types vào global scope, kể cả những cái bạn không dùng. Cài @types/node là process, Buffer bỗng xuất hiện khắp nơi. types khoá lại danh sách:
1
2
3
4
5
{
"compilerOptions": {
"types": ["node"]
}
}Chỉ package liệt kê ở đây mới được nạp global; "types": [] thì không nạp gì cả. Ngoài việc chặn type của môi trường này lẫn sang môi trường khác (chi tiết ở bài tsconfig phía trên), nó còn giảm số file .d.ts mà tsc phải đọc — ít @types nạp vào, ít thứ để check.
Lưu ý phiên bản
Bài viết dùng TypeScript 7 — bản latest trên npm hiện tại. Mọi flag và cú pháp tsconfig ở đây đều giống TypeScript 5, nên dùng được cho cả hai.
4. Chi phí ẩn ở tầng type
Đây là phần dev hay bỏ sót. Một type nhìn gọn gàng vẫn có thể bắt compiler làm việc gấp nghìn lần. Ba thủ phạm quen mặt dưới đây, kèm cách viết lại rẻ hơn. Cú pháp của những type này (conditional, mapped, template literal) được giải thích kỹ ở bài TypeScript Types Nâng Cao; ở đây ta chỉ nói về giá của chúng.
4.1. Union quá lớn
Template literal type nhân bản các union lại với nhau, và số thành viên phình lên theo cấp số nhân:
1
2
3
4
5
6
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
// Union 10.000 thành viên (10 × 10 × 10 × 10)
type Pin = `${Digit}${Digit}${Digit}${Digit}`;
const pin: Pin = "1234"; // ✅ ok, nhưng compiler phải giữ cả 10.000 thành viênMỗi thao tác sau đó trên Pin — gán, so khớp, map qua nó — đều phải duyệt qua toàn bộ union. Thêm một Digit nữa thành Pin năm chữ số là 100.000 thành viên. Cách rẻ hơn: đừng ép type mô tả mọi giá trị hợp lệ. Dùng string rồi validate lúc runtime, hoặc một branded type để vẫn phân biệt được ở compile time mà không sinh union:
1
2
3
4
5
6
7
// Không sinh union — validate lúc runtime
type Pin = string & { readonly __brand: "Pin" };
function toPin(raw: string): Pin {
if (!/^\d{4}$/.test(raw)) throw new Error("Pin phải là 4 chữ số");
return raw as Pin;
}4.2. Conditional type đệ quy sâu
Type đệ quy chạy lại chính nó cho mỗi bước — và với input dài, số bước bùng nổ. Kiểu Split một chuỗi thành tuple là ví dụ kinh điển:
1
2
3
4
5
6
type Split<S extends string, D extends string> = S extends `${infer Head}${D}${infer Tail}`
? [Head, ...Split<Tail, D>]
: [S];
// Mỗi dấu phẩy là một vòng đệ quy
type Parts = Split<"a,b,c,d,e", ",">; // ["a", "b", "c", "d", "e"]Với chuỗi vài phần tử thì không sao, nhưng nếu bạn đưa vào một CSV dài hàng trăm cột, đệ quy vừa chậm vừa dễ chạm giới hạn instantiation của compiler (Type instantiation is excessively deep). Cách rẻ hơn: nhận ra rằng bạn hiếm khi cần type biết chính xác từng phần tử. Tách chuỗi lúc runtime và để type là string[]:
1
2
3
4
// Runtime làm việc tách chuỗi; type chỉ cần biết kết quả là string[]
function splitCsv(line: string): string[] {
return line.split(",");
}4.3. Mapped type lồng nhau
DeepReadonly hay DeepPartial đệ quy qua toàn bộ cây object, tạo một type mới cho mỗi tầng lồng:
1
2
3
4
5
6
7
8
9
10
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
interface AppConfig {
server: { host: string; ports: number[]; tls: { cert: string; key: string } };
}
// Compiler dựng lại một type readonly cho từng tầng: server, ports, tls...
type Frozen = DeepReadonly<AppConfig>;Trên một object config lớn, chi phí này nhân theo độ sâu và số property. Cách rẻ hơn: đừng deep-map nếu không cần. Nếu bạn chỉ muốn một giá trị bất biến, as const cho bạn readonly sâu miễn phí ngay tại literal, không cần type đệ quy nào:
1
2
3
4
5
// as const: readonly sâu, không tốn một mapped type nào
const config = {
server: { host: "localhost", ports: [80, 443] },
} as const;
// config.server.ports[0] có type 80, và readonly toàn bộ5. interface, intersection và annotation ở biên
Hai thói quen nhỏ dưới đây giảm chi phí type-check mà gần như không đổi cách bạn viết code.
5.1. Ưu tiên interface + extends thay vì intersection
Đây là khuyến nghị của chính team TypeScript. Với object type lớn, hãy ghép bằng interface ... extends thay vì intersection &:
1
2
3
4
5
6
7
8
9
10
11
12
13
// ✅ Rẻ: interface được cache lại sau khi resolve một lần
interface Base {
id: string;
createdAt: number;
}
interface User extends Base {
name: string;
email: string;
}
interface Admin extends User {
role: "admin";
permissions: string[];
}1
2
3
4
// ❌ Đắt hơn: mỗi & tạo một type ẩn danh mới, phải resolve lại
type Base = { id: string; createdAt: number };
type User = Base & { name: string; email: string };
type Admin = User & { role: "admin"; permissions: string[] };Khác biệt nằm ở cách compiler lưu kết quả. Khi bạn extends một interface, TypeScript resolve danh sách property một lần rồi cache lại; lần sau gặp Admin nó dùng luôn bản đã cache. Còn intersection A & B & C là một type ẩn danh: mỗi lần dùng, compiler phải hợp nhất lại các thành phần và kiểm tra chúng có xung đột property không. Trên codebase lớn với nhiều tầng ghép, khoản “resolve lại” đó cộng dồn thành thời gian thật. Hai kiểu trên cho ra shape giống hệt nhau khi dùng — nên đây là đổi rẻ lấy nhanh mà không mất gì.
Đổi lại, interface chỉ mô tả được object. Khi cần union, tuple, hay mapped type thì vẫn phải dùng type — đó là lúc type không thể thay thế.
5.2. Ghi rõ return type ở biên export
Khi một hàm export không khai báo return type, compiler phải suy luận type đó, và mọi file import hàm này có thể phải suy luận lại. Với return value phức tạp (object lồng nhau, generic), khoản suy luận đó lặp lại tốn kém:
1
2
3
4
// ❌ Không có return type — tsc phải suy luận, và suy luận lại ở nơi import
export function loadConfig(env: string) {
return { env, debug: env === "development", retries: 3, endpoints: [] as string[] };
}1
2
3
4
5
6
7
8
9
10
// ✅ Có return type tường minh — tsc dùng luôn, khỏi suy luận
interface AppConfig {
env: string;
debug: boolean;
retries: number;
endpoints: string[];
}
export function loadConfig(env: string): AppConfig {
return { env, debug: env === "development", retries: 3, endpoints: [] };
}Đây cũng là cách chặn “generic instantiation bùng nổ”: khi type ở biên được ghi rõ, compiler không cần instantiate lại chuỗi generic để đoán ra nó ở mỗi call site. Annotation tường minh biến một phép suy luận lặp đi lặp lại thành một phép tra cứu.
6. Tách type-check khỏi bundling
Hiểu lầm tốn thời gian nhất: coi tsc vừa là công cụ check type vừa là công cụ build ra JavaScript. Thực tế, hai việc đó nên tách rời.
Để tạo file JavaScript, các công cụ như esbuild, swc hay Vite chỉ xoá phần type đi (transpile-only) mà không type-check — và vì bỏ qua khâu type-check, chúng nhanh hơn tsc nhiều lần. Nhưng “không type-check” nghĩa là chúng cũng không bắt lỗi type. Nên mô hình đúng là:
- Lúc dev: dùng esbuild/swc/Vite để transpile — server khởi động và hot-reload gần như tức thì.
- Kiểm tra type: chạy
tsc --noEmitriêng, không sinh file, chỉ để báo lỗi.
1
2
# tsc chỉ làm đúng một việc: check type, không sinh file
tsc --noEmitTrong CI, cho bước tsc --noEmit chạy song song với bước build và test, thay vì nối tiếp — type-check không phụ thuộc vào output của bundler, nên chẳng có lý do bắt chúng chờ nhau.
6.1. isolatedDeclarations để emit declaration song song
Nếu bạn publish package lên npm, bạn cần file .d.ts. Thường chỉ tsc sinh được chúng, vì nó phải suy luận type cho các export. isolatedDeclarations (ổn định từ TypeScript 5.5) đổi luật chơi: nó bắt mọi export phải có type tường minh, để công cụ ngoài (esbuild, swc) tự emit .d.ts mà không cần type-checker — mở đường cho việc build declaration song song từng file.
1
2
3
4
5
6
7
8
function compute() {
return { total: 42, items: ["a", "b"] };
}
// Với isolatedDeclarations: true, dòng này báo lỗi
export function getSummary() {
return compute();
}Bật isolatedDeclarations: true, compiler từ chối ngay:
1
error TS9013: Expression type can't be inferred with --isolatedDeclarations.Cách sửa đúng như mục 5.2: ghi rõ return type cho hàm export. Đổi lại chút gõ thêm, bạn được đảm bảo mỗi file có thể emit declaration độc lập — và đó chính là điều kiện để song song hoá.
Tóm lại, tối ưu compile TypeScript đi theo đúng thứ tự: đo trước bằng --extendedDiagnostics và analyze-trace, rồi cắt việc thừa (incremental, project references, skipLibCheck, types), rồi mới sửa những type đắt đỏ ở tầng type. Và đừng bắt tsc gánh việc bundling — để nó làm đúng một việc là check type. Hãy thêm --extendedDiagnostics vào lần build tiếp theo của bạn: chỉ số Check time với Instantiations sẽ cho bạn biết nên bắt đầu từ đâu.