TypeScript Toolchain hiện đại: Strict Compiler, Linting, Testing và Build nhanh


Tuan Duc Tran

Một codebase TypeScript tốt không chỉ được quyết định bởi cách viết type. Compiler, module resolver, linter, test runner và CI cùng tạo thành một hệ thống feedback. Nếu một mắt xích quá lỏng, type an toàn trên editor vẫn có thể bị phá vỡ bởi dữ liệu runtime, import sai môi trường hoặc một build không kiểm tra đúng source file.

Mục tiêu của một toolchain hiện đại không phải là bật mọi option có thể. Mục tiêu là làm cho mỗi lớp kiểm tra trả lời một câu hỏi cụ thể: code có hợp lệ theo runtime hay không, contract có nhất quán không, hành vi có đúng không, và thay đổi có thể được phát hành lặp lại hay không.

1. Bắt đầu từ môi trường chạy

tsconfig.json không phải một file cấu hình chung cho mọi loại dự án. Một ứng dụng chạy qua bundler, một package được publish cho Node.js và một thư viện dùng trong browser có yêu cầu module khác nhau. Chọn compiler options theo môi trường đầu ra trước khi chọn theo thói quen.

TypeScript Documentation phân biệt rõ app dùng bundler, code chạy trực tiếp trong Node.js và library phát hành cho consumer. Với app Astro hoặc Vite, bundler thường chịu trách nhiệm emit và bundle, vì vậy cấu hình thường dùng moduleResolution: "bundler", noEmit: trueverbatimModuleSyntax: true. Với package phát hành cho Node.js, module: "nodenext" và extension của import có thể trở thành một phần contract.

Một tsconfig cho ứng dụng bundler có thể bắt đầu như sau:

tsconfig.app.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "verbatimModuleSyntax": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "skipLibCheck": true
  },
  "include": ["src", "tests"]
}

Đây là template định hướng, không phải danh sách bắt buộc cho mọi dự án. skipLibCheck có thể giảm chi phí kiểm tra declaration của dependency, nhưng không nên được hiểu là bỏ qua type-check của source code. noEmit phù hợp khi framework hoặc bundler đảm nhiệm việc build; nếu package cần phát hành JavaScript và declaration, hãy dùng cấu hình emit riêng.

2. strict là baseline, không phải đích đến

strict: true bật một nhóm kiểm tra nghiêm ngặt cùng lúc, trong đó có strictNullChecks, noImplicitAny, strictFunctionTypes và các kiểm tra liên quan. Đây nên là baseline cho code mới vì nó buộc boundary phải nói rõ khi một giá trị có thể là null, undefined hoặc chưa được biết.

Tuy nhiên, strict mode không bảo vệ dữ liệu đến từ network, local storage hoặc DOM. TypeScript kiểm tra tĩnh; nó không thể nhìn xuyên qua một JSON response lúc runtime. Do đó, mọi boundary bên ngoài phải có validation hoặc một parser có lỗi rõ ràng.

interface User {
  id: string
  name: string
}
 
function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) {
    return false
  }
 
  const record = value as Record<string, unknown>
  return typeof record.id === 'string' && typeof record.name === 'string'
}
 
function parseUser(value: unknown): User {
  if (!isUser(value)) {
    throw new Error('Dữ liệu user không hợp lệ')
  }
 
  return value
}

Nếu dự án dùng schema library như Zod, có thể thay type guard thủ công bằng schema runtime và suy luận type từ schema. Dù chọn cách nào, nguyên tắc vẫn giống nhau: unknown ở boundary, type cụ thể sau khi validation thành công.

3. Những compiler option đáng cân nhắc

Không phải option nào cũng nên bật ngay trong codebase cũ. Hãy bật theo nhóm, sửa lỗi theo nguyên nhân và giữ commit nhỏ để biết thay đổi nào tạo ra tác động.

OptionGiá trị thiết kếKhi cần thận trọng
noUncheckedIndexedAccessTruy cập array[index]record[key] phản ánh khả năng không có giá trị.Có thể tạo nhiều lỗi ở code legacy đang giả định index luôn tồn tại.
exactOptionalPropertyTypesPhân biệt property bị thiếu với property có giá trị undefined.Cần rà lại DTO, options object và spread pattern.
noImplicitReturnsBắt hàm có nhánh không trả về giá trị nhất quán.Cần xử lý callback hoặc function cố tình chỉ thực hiện side effect.
noImplicitOverrideBuộc ghi override khi ghi đè method của base class.Hữu ích khi dùng inheritance; không cần thiết nếu code chủ yếu composition.
verbatimModuleSyntaxGiữ import/export gần với source và làm rõ type-only import.Cần đồng bộ với module resolver và bundler.
noUnusedLocalsBắt local type, variable hoặc import không được dùng.Có thể cần điều chỉnh file generated hoặc declaration đặc biệt.

Một option chỉ có giá trị khi team hiểu lỗi mà nó ngăn chặn. Đừng bật exactOptionalPropertyTypes chỉ để làm checklist đẹp; hãy bật khi model optional property phản ánh đúng semantics của API.

4. Module resolution phải khớp với nơi code chạy

Một trong những lỗi khó chẩn đoán nhất là code pass type-check nhưng fail khi chạy vì TypeScript và runtime giải quyết module khác nhau. module, moduleResolution, package.json field type, export map và bundler cần được xem như một nhóm.

Với bundler, TypeScript Documentation khuyến nghị cấu hình theo bundler và dùng verbatimModuleSyntax để import type không bị emit như import runtime. Với Node.js ESM, import tương đối trong output có thể cần extension .js dù source viết bằng .ts.

// Type-only import sẽ bị loại khỏi output JavaScript.
import type { User } from './user.js'
import { createUser } from './user.js'
 
export function createAdmin(): User {
  return createUser({ role: 'admin' })
}

Tên file trong ví dụ Node.js ESM có .js vì đó là extension của output runtime. Không nên sao chép quy tắc này vào mọi app bundler mà không kiểm tra module resolver đang dùng.

Hãy tránh import từ barrel file quá lớn nếu nó tạo cycle hoặc khiến dependency graph khó hiểu. Một boundary tốt có thể có index.ts, nhưng public export nên được thiết kế có chủ đích thay vì re-export mọi file trong thư mục.

5. satisfies để kiểm tra contract mà vẫn giữ inference

Type assertion nói với compiler rằng người viết tin một giá trị có type nào đó. satisfies làm việc khác: nó kiểm tra giá trị có phù hợp với một contract hay không mà vẫn giữ type cụ thể được suy luận từ literal.

type RouteConfig = {
  path: `/${string}`
  auth: 'public' | 'private'
}
 
const routes = {
  home: { path: '/', auth: 'public' },
  settings: { path: '/settings', auth: 'private' },
} satisfies Record<string, RouteConfig>
 
routes.home.path
routes.settings.auth

Nếu dùng as Record<string, RouteConfig>, thông tin cụ thể của từng property có thể bị nới rộng. Với satisfies, typo ở key hoặc giá trị sai contract bị phát hiện nhưng routes.home vẫn được giữ trong inference.

as vẫn cần thiết ở một số boundary đã được runtime kiểm tra hoặc API có type declaration chưa đủ chính xác. Nhưng assertion nên đi kèm lý do và càng gần điểm chứng minh invariant càng tốt.

6. Typed linting: lint syntax và lint type là hai lớp khác nhau

Linting không thay thế compiler. Lint syntax có thể phát hiện code smell mà compiler không quan tâm; typed linting dùng thông tin TypeScript để phát hiện promise bị bỏ quên, unsafe assignment, unsafe member access hoặc type assertion đáng ngờ.

Một cấu hình typed linting nên dùng project service hoặc project config đúng với source. Đừng áp dụng typed linting vào toàn bộ node_modules, thư mục build hoặc file generated. Khi bật rule type-aware trên repository lớn, hãy đo thời gian lint và phân chia scope nếu cần.

eslint.config.ts
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
 
export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strictTypeChecked,
  {
    files: ['src/**/*.ts', 'src/**/*.vue'],
    languageOptions: {
      parserOptions: {
        projectService: true,
      },
    },
  },
)

Nếu repo đã dùng Biome thay vì ESLint, không nên cài thêm ESLint chỉ để có thêm một công cụ. Hãy xác định rule nào còn thiếu, xem Biome có hỗ trợ hay không, rồi bổ sung typed check hoặc một bước kiểm tra hẹp hơn. Toolchain tốt là toolchain có feedback đủ và dễ hiểu, không phải toolchain có nhiều package nhất.

7. Testing: kiểm tra cả runtime lẫn type contract

Unit test kiểm tra hành vi runtime. Type test kiểm tra API có chấp nhận hoặc từ chối đúng cách. Hai loại test bổ sung cho nhau.

interface Paginated<T> {
  items: T[]
  nextCursor?: string
}
 
function firstItem<T>(page: Paginated<T>): T | undefined {
  return page.items[0]
}
 
const page: Paginated<number> = {
  items: [1, 2, 3],
}
 
const first: number | undefined = firstItem(page)

Một unit test nên kiểm tra firstItem(page) trả về item đầu tiên và undefined khi mảng rỗng. Một type test nên bảo đảm Paginated<number> không thể truyền vào function yêu cầu Paginated<string>.

Có thể dùng tsc --noEmit trong CI để kiểm tra type contract, hoặc dùng framework type-test chuyên biệt nếu API public có nhiều overload và conditional type. Đừng biến mọi dòng code thành type test; chỉ test những contract mà thay đổi type có thể phá vỡ consumer.

Với test bất đồng bộ, luôn xử lý rejected promise và timeout. Nếu hai request độc lập, Promise.all thể hiện ý định song song rõ hơn việc await tuần tự trong loop.

async function loadDashboard(): Promise<Dashboard> {
  const [profile, notifications] = await Promise.all([
    loadProfile(),
    loadNotifications(),
  ])
 
  return { profile, notifications }
}

Ngược lại, nếu bước sau phụ thuộc kết quả bước trước hoặc API có rate limit, tuần tự có thể là lựa chọn đúng. Performance guideline chỉ có ý nghĩa sau khi hiểu dependency và failure semantics.

8. Tối ưu thời gian build một cách có đo lường

incremental cho phép compiler lưu thông tin giữa các lần chạy. Với codebase lớn, project references chia hệ thống thành các project nhỏ hơn, tạo dependency graph và hỗ trợ tsc --build build theo thứ tự cần thiết.

Project references phù hợp khi repo có boundary thật, chẳng hạn một package dùng chung, một server và một app riêng. Không nên tách thành hàng chục project chỉ để làm tsconfig dài hơn. Mỗi reference mang theo chi phí declaration, output và cấu hình build.

tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./packages/core" },
    { "path": "./apps/web" }
  ]
}

Project được reference cần composite: true, thường đi cùng declaration: true. tsc --build có thể phát hiện project nào đã up-to-date và build dependency theo thứ tự. Hãy ghi lại command build chính thức trong package script để local và CI không chạy hai quy trình khác nhau.

Ngoài compiler, hãy giảm scope cần scan. Exclude generated files, coverage, output và fixture lớn nếu chúng không thuộc source cần type-check. Đo bằng tsc --extendedDiagnostics khi nghi ngờ type system chậm, thay vì suy đoán từ cảm giác editor.

9. Public API và generated declaration

Nếu package được publish, declaration file là một phần sản phẩm. Type export quá phức tạp có thể làm consumer chậm, tạo lỗi khó đọc hoặc phụ thuộc vào implementation không ổn định.

Một public function nên có return type rõ khi return type là contract cần giữ ổn định. Generic nên có constraint phản ánh capability cần dùng, không nên thêm nhiều type parameter chỉ để làm signature trông linh hoạt.

interface HasId {
  id: string
}
 
export function indexById<T extends HasId>(items: readonly T[]): Map<string, T> {
  return new Map(items.map((item) => [item.id, item]))
}

T extends HasId nói rằng function chỉ cần một capability là id. Nó không yêu cầu class cụ thể và vẫn giữ type của item trong Map. Đây là cách structural typing hỗ trợ API nhỏ và dễ tái sử dụng.

10. CI nên kiểm tra đúng những gì local kiểm tra

Một pipeline tối thiểu nên có format/lint, type-check, unit test và build. Các bước phải dùng cùng package manager, lockfile và command với local development. Nếu local chạy Bun nhưng CI chạy npm, dependency graph hoặc script behavior có thể khác.

ci.yml
steps:
  - name: Install dependencies
    run: bun install --frozen-lockfile
  - name: Check formatting and lint
    run: bun run check:biome
  - name: Type-check
    run: bun run check:astro
  - name: Unit tests
    run: bun run test:unit
  - name: Build
    run: bun run build

Tên command trong ví dụ cần thay bằng scripts thật của repo. Không nên cho CI tự sửa file bằng --write; CI nên báo lỗi để developer sửa trong branch. Các artifact như .astro, declaration hoặc build output nên được kiểm tra theo mục đích, không commit chỉ để làm cho CI xanh.

11. Lộ trình nâng cấp một codebase cũ

Nâng cấp toolchain an toàn là một chuỗi bước nhỏ. Trước hết, ghi lại runtime, bundler, package manager và command hiện tại. Sau đó bật strict theo phạm vi nhỏ, xử lý boundary unknown, thêm type-only import, thiết lập test và chỉ sau cùng mới cân nhắc option gây nhiều thay đổi như noUncheckedIndexedAccess hoặc exactOptionalPropertyTypes.

Giai đoạnMục tiêuKết quả cần quan sát
BaselineChạy command hiện tại và lưu thời gianBiết lỗi có sẵn, không nhầm lỗi mới với lỗi cũ.
CompilerBật strict và các option phù hợp runtimeLỗi tập trung ở boundary và model, không tăng vô hạn.
ModuleChuẩn hóa import, export và type-only importBuild runtime khớp với output compiler/bundler.
FeedbackThêm lint, test và type-check vào CIPull request nhận lỗi sớm với message dễ hiểu.
PerformanceĐo diagnostics, cache và project referencesThời gian build giảm mà không che giấu lỗi.

Toolchain đáng tin cậy là một thiết kế có feedback, không phải một file config dài. Khi mỗi option có lý do và mỗi command có một vai trò riêng, TypeScript trở thành hàng rào bảo vệ kiến trúc thay vì một nguồn cảnh báo gây nhiễu.

Tài liệu tham khảo

  1. TypeScript — What is a tsconfig.json
  2. TypeScript — Modules: Choosing Compiler Options
  3. TypeScript — Project References
  4. TypeScript — TSConfig Reference
  5. TypeScript — The satisfies Operator
  6. typescript-eslint — Typed Linting
  7. Bun — Test Runner