GĐ02 — TypeScript & toolchain cho Backend (học trước GĐ03)
Study note cho FE engineer (mạnh JS/TS) chuyển sang Backend. Mỗi concept: định nghĩa → tại sao quan trọng → cơ chế → ví dụ → pitfall. Đây là giai đoạn "ngày 0". Ở FE, Vite/Next đã lo hết: transpile, bundle, watch, alias, env, source map. Ở BE không có ai lo hộ — bạn tự dựng toolchain, và nếu dựng sai, mọi lỗi sau này (import không chạy, alias vỡ khi build,
__dirnameundefined) đều bắt nguồn từ đây chứ không phải từ code backend.
Kiểm chứng ngày 2026-10-05: Node 24 là LTS khuyến nghị (22 ở maintenance đến 2027-04-30, 20 đã hết hỗ trợ; 26 vào LTS ngày 2026-10-28, theo lịch phát hành chính thức https://github.com/nodejs/Release/blob/main/schedule.json). Node chạy
.tsbằng type stripping mặc định, không còn cờ--experimental-strip-types.--enable-source-mapsvẫn mặc định tắt. TypeScriptlatestlà 7.0 (bản Go):baseUrlđã bị gỡ ở 7.0 (deprecated từ 6.0, lỗi TS5101; ở 7.0 là TS5102). Kiểm tra lại các mốc này trước khi áp dụng cho dự án mới.
tsup: README của tsup ghi dự án không còn được bảo trì chủ động và trỏ sangtsdown(README tsup, hướng dẫn chuyển).tsdowncòn ở nhánh 0.x (đã chạy bản 0.23.0 ở mục 6).- Corepack: danh sách thay đổi của Node 25.0.0 có commit "stop distributing Corepack" (ghi chú Node 25.0.0). Node 24 vẫn kèm Corepack (đã chạy
corepack --versionra 0.36.0 trên Node 24.21.0).- npm 11.19.0 (đã chạy): cảnh báo
install-scriptskèm lệnhnpm install-scripts ls|approve|deny, khoáallowScriptstrongpackage.json, tuỳ chọn--min-release-age(đơn vị ngày),npm audit signatures(mục 8a).- Chưa xác minh: chi tiết pnpm 12 (thay đổi phá vỡ, Node tối thiểu, cài đặt chuỗi cung ứng). Chỉ đã chạy pnpm 11.1.3 cục bộ; bản
latesttrên registry là 12.9.1 (npm view pnpm version).
1. Vì sao backend phải tự dựng toolchain#
Định nghĩa. Toolchain = tập công cụ biến source TypeScript thành process Node chạy được: type checker, transpiler, watcher (dev), bundler/emitter (build), linter, formatter.
Tại sao quan trọng. Ở FE bạn viết import Foo from '@/components/Foo' và nó chạy — vì Vite/webpack resolve alias, transpile TS, hot-reload, inject env, tạo source map. Node không làm gì trong số đó. Node nhận một file JS và chạy. Mọi thứ ở giữa là việc của bạn.
Cơ chế. Có 2 pha tách biệt mà FE thường gộp làm một:
- Type checking —
tsc --noEmit. Chỉ kiểm tra kiểu, không sinh file. Chậm, chạy ở CI + IDE. - Transpile/emit — biến TS → JS.
tsc, hoặc esbuild/swc (nhanh gấp 10–50 lần vì không type-check, chỉ xoá type).
Đây là lý do tsx/esbuild chạy được cả file TS đang có lỗi type: chúng chỉ xoá type, không kiểm tra. Type an toàn là do tsc --noEmit ở CI đảm bảo, không phải do runtime.
Pitfall. Tin rằng "build pass = type đúng". Nếu build bằng esbuild/swc/tsdown/tsup mà CI không có bước tsc --noEmit riêng, bạn đã tắt TypeScript mà không biết — code có lỗi type vẫn deploy lên prod bình thường.
Sơ đồ: ba đường đi của code TypeScript
Ba đường này độc lập: chỉ tsc (có hoặc không --noEmit) mới kiểm tra kiểu. Cách tự kiểm: cố ý viết const n: number = 'x'; npm run dev vẫn chạy, npm run typecheck đỏ, và npm run build phải đỏ nếu script build có npm run typecheck && ở đầu.
2. tsconfig.json cho Node — từng dòng, và vì sao#
Định nghĩa. File cấu hình compiler TypeScript: nhắm runtime nào, sinh module kiểu gì, chặt tới đâu.
Tại sao quan trọng. tsconfig của FE ("lib": ["DOM"], "module": "ESNext", "noEmit": true) sao chép sang BE là sai ngay. DOM cho phép bạn gọi document và localStorage mà không báo lỗi — cho tới khi chạy trên Node và crash.
Ví dụ — tsconfig baseline cho Node 22+ / TS 6–7:
Cơ chế của 2 dòng hay bị bỏ qua.
noUncheckedIndexedAccess: mặc định TS nóiusers[999]có kiểuUser— nói dối. Bật lên thànhUser | undefined. Đây là nguồnCannot read property 'x' of undefinedphổ biến nhất ở BE khi xử lý mảng từ DB.forceConsistentCasingInFileNames: bạn dev trên macOS (case-insensitive),import './UserService'trong khi file tênuserService.tsvẫn chạy. Deploy lên Docker/Linux (case-sensitive) →MODULE_NOT_FOUND. Lỗi "chạy máy tôi ok" kinh điển số 1.
Pitfall. "skipLibCheck": false với dự án lớn khiến tsc chậm gấp nhiều lần vì phải check toàn bộ .d.ts trong node_modules — bao gồm cả những package có type lỗi mà bạn không sửa được. Gần như luôn để true.
Ba cờ để code chạy được khi chỉ bị xoá type#
tsx, esbuild, tsdown và type stripping của Node đều xử lý từng file một và chỉ xoá type. Có ba loại code mà tsc mặc định cho qua nhưng các công cụ này không chạy đúng. Ba cờ ở cuối tsconfig mẫu làm tsc báo sớm:
| Cờ | tsc bắt gì | Thông báo đã thấy (TS 6.0.3) |
|---|---|---|
erasableSyntaxOnly | cú pháp phải sinh code JS: enum, namespace, parameter property (constructor(private x: T)) | TS1294 |
verbatimModuleSyntax | import { User } khi User chỉ là type; export { User } from một type | TS1484, TS1205 |
isolatedModules | cú pháp mà một file đứng riêng không biên dịch đúng được | không tách riêng trong thử nghiệm dưới đây (chưa xác minh riêng) |
Với NestJS: parameter property (constructor(private readonly svc: S)), kiểu DI chuẩn của Nest, bị erasableSyntaxOnly báo TS1294; để cờ này cho dự án Express thuần và bỏ nó khỏi dự án Nest. Decorator thì cờ này không báo (tsc cho qua), nhưng type stripping của Node không chạy được (SyntaxError: Invalid or unexpected token ở dòng @Injectable()), nên Nest vẫn cần bước build hoặc tsx. Đã chạy: TS 6.0.3 và 7.0.2, cả có experimentalDecorators, một file có decorator lớp (không lỗi) và một file có parameter property (TS1294); node 24.21 với file decorator ra SyntaxError. Chưa chạy với một dự án Nest thật.
Bằng chứng đã chạy: cùng một mã, có và không có ba cờ
Đã chạy trên TypeScript 6.0.3 và Node 24.21.0, trong thư mục tạm không thuộc repo. Ba file lỗi là enum Role { Admin, User }, import { User, defaultId } from './types.js' (với User là interface) và export { User } from './types.js'.
Hậu quả khi bỏ qua, chạy thẳng bằng Node (type stripping):
Đã chạy lại với TypeScript 7.0.2: ba mã TS1294, TS1484, TS1205 giống hệt 6.0.3; chỉ khác mã thoát của tsc (7.0.2 thoát 1, 6.0.3 thoát 2).
3. ESM vs CommonJS — cuộc chiến thật sự của Node#
Định nghĩa. Node có hai module system:
- CommonJS (CJS) —
require()/module.exports. Đồng bộ, cũ, mặc định lịch sử. - ES Modules (ESM) —
import/export. Bất đồng bộ, chuẩn, tương lai.
Tại sao quan trọng. Ở FE bạn viết import và bundler xử lý hết — bạn chưa từng phải biết file cuối cùng là ESM hay CJS. Ở BE, Node phân biệt thật, và hai bên không trộn tự do được. Đây là nguồn lỗi số 1 của FE mới sang BE.
Cơ chế — Node quyết định file là ESM hay CJS thế nào:
| Điều kiện | Kết quả |
|---|---|
package.json có "type": "module" | .js = ESM |
Không có "type" (hoặc "type": "commonjs") | .js = CJS |
Đuôi .mjs | luôn ESM (bất kể type) |
Đuôi .cjs | luôn CJS |
.ts → .mts / .cts | tương ứng ESM / CJS |
Luật trộn:
- ESM import được CJS:
import express from 'express'→ OK (chỉ default import; named import chỉ hoạt động nhờ Node phân tích tĩnh, không phải lúc nào cũng được). - CJS KHÔNG
require()được ESM đồng bộ ở Node cũ →ERR_REQUIRE_ESM. (Node 22.12+ / 23+ cho phéprequire()một ESM không có top-level await — nhưng đừng dựa vào nó khi hỗ trợ nhiều version.)
Ví dụ — khác biệt cụ thể phải nhớ:
Pitfall — hai cái đau nhất:
-
Viết
.tsnhưng import phải ghi.js. Vớimodule: NodeNext+ ESM, bạn có fileuser.service.tsnhưng phải viếtimport ... from './user.service.js'. Trông vô lý nhưng đúng: TS không đổi đường dẫn khi emit, nên đường dẫn phải là đường dẫn sau khi build. Viết.ts→ build ra JS rồi crashMODULE_NOT_FOUNDở runtime, màtsckhông hề báo lỗi lúc build. -
Package ESM-only. Nhiều package hiện đại (
chalkv5+,node-fetchv3+,nanoidv4+) bỏ CJS hoàn toàn. Project CJSrequire('chalk')→ nổ. Cách xử: dùngawait import('chalk')(dynamic import chạy được từ CJS), hoặc pin version cũ, hoặc chuyển hẳn project sang ESM.
Khuyến nghị cho học viên. Chọn ESM ("type": "module") cho project mới và đi tới cùng — đó là hướng của hệ sinh thái. Về NestJS: bản cũ từng mặc định CJS (vì decorator + emitDecoratorMetadata + reflect-metadata), nhưng Nest 12 là ESM-only (kiểm chứng ngày 2026-10-05, xem GĐ07), nên chọn ESM xuyên suốt không còn mâu thuẫn với GĐ07; khi gặp tutorial Nest cũ viết require, đó là tài liệu cũ.
Kết quả mong đợi: đoán ESM hay CJS và các lỗi điển hình
Chưa chạy: kết quả suy ra từ bảng quyết định ở trên và tài liệu Node; thông báo lỗi có thể khác chữ giữa các bản Node.
package.json | Tên file | Loại | Ghi chú |
|---|---|---|---|
"type": "module" | app.js | ESM | import, có import.meta.dirname |
không có type | app.js | CJS | require, có __dirname |
| bất kỳ | app.mjs / app.cjs | ESM / CJS | đuôi thắng type |
"type": "module" | legacy.cjs | CJS | file đơn lẻ vẫn là CJS |
"type": "module" | app.mts | ESM (TypeScript) | emit ra .mjs |
Lỗi hay gặp, chạy được trong vài giây để tự thấy:
4. Path alias — và bẫy "chạy dev ok, build xong vỡ"#
Định nghĩa. Alias = ánh xạ đường dẫn ngắn (@/services/user) sang đường dẫn thật (src/services/user).
Tại sao quan trọng. Không có alias, module lồng sâu sinh ra import ... from '../../../../config/env.js' — di chuyển file là vỡ hàng loạt.
Cơ chế — và cái bẫy. tsconfig.paths chỉ dạy TypeScript hiểu alias để type-check. Nó không ghi lại đường dẫn khi emit. File JS sinh ra vẫn chứa nguyên chuỗi "@/services/user" — và Node không biết @ là gì → crash lúc chạy.
Ví dụ — 3 cách xử lý, chọn 1:
Đã chạy tsc --noEmit trên TS 6.0.3 và 7.0.2: có baseUrl thì 6.0 lỗi TS5101 và 7.0 lỗi TS5102; chỉ dùng paths với đích ./src/* thì cả hai pass.
Pitfall. Dev bằng tsx (tự đọc tsconfig.paths → chạy ngon), rồi build bằng tsc thuần và deploy → prod crash Cannot find module '@/config/env' ngay lúc boot. Lỗi này chỉ xuất hiện ở prod, không bao giờ thấy lúc dev. Luôn chạy thử npm run build && node dist/main.js ở local trước khi tin toolchain của mình.
5. Chạy dev — watch mode#
Định nghĩa. Chạy TS trực tiếp, tự restart khi đổi file.
Cơ chế & lựa chọn:
| Cách | Lệnh | Ghi chú |
|---|---|---|
| tsx (khuyên dùng) | tsx watch src/main.ts | esbuild bên dưới, rất nhanh, hiểu tsconfig.paths, chạy cả ESM/CJS |
| Node native | node --watch src/main.ts | Type stripping bật mặc định (Node 22.18+/23.6+), không cần cờ; tắt bằng --no-strip-types. Chỉ xoá type, không hỗ trợ enum/namespace/decorator; nên bật erasableSyntaxOnly trong tsconfig để tsc báo sớm enum/namespace/parameter property (decorator thì cờ không báo, Node vẫn SyntaxError) |
ts-node + nodemon | nodemon --exec ts-node src/main.ts | Cũ, chậm hơn (type-check mỗi lần restart), còn gặp nhiều ở dự án legacy |
| NestJS | nest start --watch | Nest lo sẵn, đừng tự chế |
Ví dụ scripts:
Pitfall. tsx không type-check → bạn code cả buổi thấy "chạy ngon", tới lúc CI chạy tsc --noEmit thì đỏ 40 lỗi. Cách chống: mở IDE có TS server (VSCode làm sẵn) + chạy npm run typecheck trước mỗi commit (hoặc gắn vào pre-commit hook).
6. Build cho production#
Định nghĩa. Sinh JS chạy được, tối ưu cho môi trường prod.
Cơ chế & lựa chọn:
| Công cụ | Type-check? | Tốc độ | Khi nào |
|---|---|---|---|
tsc | Có | Chậm | Mặc định an toàn. NestJS dùng cái này |
tsdown (rolldown) | Không | Rất nhanh | App/lib cần bundle; kế nhiệm tsup, còn ở nhánh 0.x nên ghim version |
tsup (esbuild) | Không | Rất nhanh | Còn nhiều dự án dùng, nhưng README ghi không được bảo trì chủ động: dự án mới đừng chọn |
swc | Không | Rất nhanh | Thay tsc trong Nest/Jest khi dự án lớn |
Ví dụ — cấu hình tsdown (đã chạy tsdown 0.23.0 + TypeScript 6.0.3 + Node 24.21.0 trên một skeleton nhỏ import zod):
Ba điều đã thấy khi chạy:
- Mặc định (không có
fixedExtension: false) đầu ra làdist/main.mjs, nên scriptstartphải trỏ.mjshoặc đặt cờ như trên. dependenciesvàpeerDependenciestrongpackage.jsonmặc định là external:dist/main.jsmở đầu bằngimport { z } from "zod", không nhúng codezod. Native module vì thế không bị bundle.tsconfig.pathsđược tsdown resolve lúc bundle (mục 4, bước 6 của bài thực hành).
Muốn giữ tsc làm công cụ build duy nhất (không bundle): bật rewriteRelativeImportExtensions và allowImportingTsExtensions trong tsconfig.json, rồi viết import { greet } from './greet.ts'. Đã chạy: tsc emit ra dist/main.js chứa import { greet } from "./greet.js" và node dist/main.js chạy được; Node cũng chạy thẳng src/main.ts. Giới hạn đã thấy: cờ chỉ viết lại đường dẫn tương đối; import qua alias @/greet.ts bị TS2877. Chọn một quy ước (.js hoặc .ts trong specifier) cho cả dự án.
Cấu hình tsup tương đương (cho dự án cũ)
Đã chạy tsup 8.5.1 với cấu hình này: build ra dist/main.js và dist/main.js.map, chạy được với --enable-source-maps.
Pitfall. Bundle luôn node_modules để "gọn hơn" → vỡ với native addon (bcrypt, sharp, better-sqlite3 — chúng có file .node nhị phân) và vỡ với package dùng __dirname để tìm asset (Prisma engine, template file). Ở backend, bundle dependencies hiếm khi đáng; chỉ làm khi tối ưu cold start serverless và đã test kỹ.
7. package.json chuẩn cho service backend#
Ví dụ đầy đủ:
Cơ chế của các trường ít người để ý.
engines— không tự chặn, nhưng npm cảnh báo và nhiều PaaS (Render, Railway, Heroku) dùng nó để chọn Node version khi deploy.packageManager+ Corepack — ghi version package manager vào repo để dev/CI/prod dùng cùng một version, tránh lockfile bị resolve khác nhau. Corepack đi kèm Node 24 (đã chạycorepack --versionra 0.36.0) nhưng không còn được phân phối từ Node 25 (nguồn ở đầu file); cách cài riêng và hành vi chi tiết của pnpm 12 chưa xác minh. Bài thực hành dùng npm; bản pnpm tương ứng ghi ở mục 8..nvmrc(file riêng, chứa đúng24) —nvm usetự chọn version; GitHub Actionssetup-nodeđọc được quanode-version-file.
Pitfall. Quên "private": true ở project công ty → một lệnh npm publish gõ nhầm là source code nội bộ nằm trên npm registry công khai vĩnh viễn. Không undo được (unpublish có giới hạn 72h và vẫn có mirror).
8. Package manager & lockfile#
Định nghĩa. Lockfile (package-lock.json, pnpm-lock.yaml) ghim chính xác version của toàn bộ cây dependency, kể cả dependency của dependency.
Tại sao quan trọng. "express": "^5.1.0" nghĩa là "5.1.0 trở lên, dưới 6". Không có lockfile, hôm nay bạn cài 5.1.0, tuần sau CI cài 5.3.2 → build khác nhau, bug xuất hiện ngẫu nhiên mà không ai đổi dòng code nào.
Cơ chế.
npm install— có thể sửa lockfile để thoảpackage.json. Dùng ở máy dev.npm ci— xoánode_modules, cài đúng y hệt lockfile, lỗi ngay nếu lockfile lệchpackage.json. Dùng ở CI và Docker build, luôn luôn.pnpm install --frozen-lockfile— tương đươngnpm cicủa pnpm (đã chạy pnpm 11.1.3: lockfile khớp thì inAlready up to date);pnpm addthay chonpm i.pnpm— lưu package một lần trên đĩa, hardlink vào từng project (tiết kiệm hàng GB) và strict hơn: chặn "phantom dependency" — dùng package mà không khai báo trongpackage.json.
Pitfall. Dùng npm install trong Dockerfile. Nó có thể resolve version khác lockfile → image prod khác hẳn cái bạn test ở local, đúng loại bug khó nhất để truy. Trong Docker luôn là RUN npm ci --omit=dev.
8a. Chuỗi cung ứng: giữ code lạ ngoài máy bạn#
Định nghĩa. Mỗi npm install kéo về hàng trăm package của người lạ; một số có install script chạy ngay trên máy dev và CI. Hygiene chuỗi cung ứng = thu hẹp những gì được phép tự chạy hoặc tự đổi.
Cơ chế — bốn lớp, mỗi lớp có lệnh chứng minh (đã chạy npm 11.19.0, Node 24.21.0):
| Lớp | Việc cần làm | Đã thấy khi chạy |
|---|---|---|
Lockfile + npm ci | commit lockfile; CI chỉ chạy npm ci (mục 8) | sửa tay typescript thành ^5.9.0 rồi npm ci: EUSAGE, Invalid: lock file's typescript@6.0.3 does not satisfy typescript@5.9.3 |
| Install script | duyệt từng package được chạy script; mặc định đừng tin. Chưa duyệt thì npm 11 vẫn chạy script kèm cảnh báo; muốn chặn thật thì đặt strict-allow-scripts=true (lỗi cứng) hoặc ignore-scripts=true | cài tsx (trong lệnh npm i -D của bài thực hành) in npm warn install-scripts 1 package has install scripts not yet covered by allowScripts: esbuild@0.28.2 (postinstall: node install.js); npm install-scripts ls liệt kê; npm install-scripts approve esbuild ghi "allowScripts": { "esbuild@0.28.2": true } vào package.json; sau đó ls báo No packages with unreviewed install scripts.; npx tsx chạy được ngay cả trước khi duyệt. Đã chạy riêng với một package .tgz cục bộ có postinstall ghi file: mặc định in cảnh báo not yet covered by allowScripts và file vẫn được tạo; --strict-allow-scripts=true dừng với ESTRICTALLOWSCRIPTS và không tạo file; --ignore-scripts=true không chạy script, không cảnh báo |
| Chữ ký và nguồn gốc | kiểm sau khi cài | npm audit signatures: 96 packages have verified registry signatures, 30 packages have verified attestations |
| Chờ bản mới đủ tuổi | --min-release-age=<số ngày> | npm i typescript --dry-run: tuổi 7 ngày ra typescript 7.0.2 (phát hành 2026-07-08); 120 ngày ra 6.0.3; 1000 ngày ra 5.3.3 |
Lý do dùng min-release-age: bản bị chiếm quyền publish thường bị phát hiện và gỡ sau vài giờ đến vài ngày, nên cài chậm một nhịp rẻ hơn cài ngay (đây là lập luận, không phải số đo). Để đặt thường trực, đưa min-release-age vào .npmrc (chưa chạy qua .npmrc; mới chạy bằng cờ --min-release-age). Tắt hẳn mọi install script bằng --ignore-scripts hoặc ignore-scripts=true (chưa chạy; mặc định npm config get ignore-scripts ra false), đổi lại một số package (chứa binary) có thể cần duyệt từng cái.
Pitfall. Coi npm audit là đủ. npm audit chỉ đối chiếu lỗ hổng đã công bố; nó không chặn một bản độc mới, không cho biết package có script chạy lúc cài, và không nói gì về nguồn gốc bản build. Chưa xác minh: các cài đặt tương đương của pnpm 12.
Tự làm (mỗi lớp một lệnh):
-
Trong thư mục tạm,
npm init -y,npm i -D typescript@6, giữ nguyênpackage-lock.json. Sửapackage.jsonthành"typescript": "^5.9.0"rồi chạynpm ci. Sau đó khôi phụcpackage.jsonvề^6, rồi (đã chạy) thửnpm i typescript --min-release-age=120 --dry-runở hai nơi: trong chính thư mục này và trong một thư mục mới (npm init -y, chưa cài gì). Mỗi lệnh cho thấy điều gì?Đáp án
npm ciphải thất bại vớiEUSAGEvà dòngInvalid: lock file's typescript@6.0.3 does not satisfy typescript@5.9.3(lockfile lệchpackage.json,npm cikhông tự sửa). Đã chạy trên npm 11.19.0: ở thư mục này,--dry-runkhi chưa khôi phụcpackage.jsoninchange typescript 6.0.3 => 5.9.3, còn sau khi khôi phục inup to date(lockfile đã có6.0.3, không có gì để thêm). Chỉ trong thư mục mới, rỗng,--dry-runvới--min-release-age=120mới inadd typescript 6.0.3; lệnh không ghi gì vào đĩa. số phiên bản cụ thể thay đổi theo ngày bạn chạy vì nó phụ thuộc ngày phát hành của từng bản (7.0.2 ra ngày 2026-07-08 nên chưa đủ 120 ngày vào 2026-10-05). Lỗi hay gặp: quên khôi phụcpackage.jsontrước khi chạy tiếp; chạynpm installthay chonpm cirồi thấy lockfile bị sửa im lặng.
9. ESLint (flat config) + Prettier#
Định nghĩa. ESLint bắt lỗi logic/pattern nguy hiểm. Prettier format code (chỉ thẩm mỹ). Hai việc khác nhau, đừng gộp.
Tại sao quan trọng ở BE hơn FE. Ở FE lỗi format là xấu code. Ở BE có những rule bắt bug thật: no-floating-promises (quên await → lỗi bị nuốt, transaction không commit), require-await, no-misused-promises (truyền async function vào chỗ mong đợi hàm sync → Express nuốt lỗi luôn).
Ví dụ — eslint.config.js (flat config, ESLint 10; kiểm chứng ngày 2026-10-05 tseslint.config() đã deprecated nên dùng defineConfig của ESLint; typescript-eslint 8 có peer typescript <6.1, nên với TS 7 cần gói tương thích TS 6 hoặc giữ TS 6 cho bước lint):
Cơ chế no-floating-promises — vì sao nó đáng giá nhất:
Pitfall. Bật rule format trong ESLint (indent, quotes) trong khi vẫn dùng Prettier → hai bên đánh nhau, save file là code nhảy qua nhảy lại. Để Prettier lo format, ESLint lo logic; nếu muốn chắc, thêm eslint-config-prettier để tắt hết rule format của ESLint.
10. strict: true không bảo vệ được biên hệ thống#
Định nghĩa. TypeScript chỉ tồn tại lúc compile. Runtime không có type nào cả.
Tại sao quan trọng — đây là hiểu lầm chết người khi từ FE sang. Ở FE, data thường đến từ code của chính bạn. Ở BE, mọi thứ đi vào đều là dữ liệu lạ, không đáng tin: request body, query string, header, biến môi trường, response của API bên thứ ba, row từ DB do raw query. Gắn type cho chúng chỉ là lời hứa, không phải kiểm tra.
Ví dụ — cái sai và cái đúng:
Ví dụ — env cũng là biên:
Pitfall. Đọc process.env.X rải rác khắp codebase. Typo DATABSE_URL cho ra undefined im lặng, rồi lỗi hiện ra ở tận tầng DB dưới dạng thông báo khó hiểu. Quy tắc: process.env chỉ được đọc trong đúng một file — config/env.ts. Chỗ khác import env từ đó.
Kết quả mong đợi: as so với zod ở biên
Chưa chạy: suy ra từ hành vi của TypeScript và zod 4; chữ trong thông báo lỗi có thể đổi giữa các bản zod.
Với env: thiếu DATABASE_URL thì schema.parse(process.env) ném ZodError ngay khi import config/env.ts, trước khi server listen, nên process thoát với exit code khác 0 và stack trace chỉ vào file này.
11. Debug — thay thế cho DevTools#
Định nghĩa. Backend không có Chrome DevTools tab Elements/Network. Công cụ tương đương là Node inspector (chính là DevTools protocol) + structured log.
Cơ chế.
Ví dụ — .vscode/launch.json:
Pitfall. Có file .map chưa đủ: Node không tự đọc source map. Mặc định stack trace trỏ vào file JS đã build (dist/main.js:...), vô dụng khi bundle gộp nhiều file hoặc code đã bị biến đổi. Phải bật cả hai: sourcemap: true lúc build và chạy Node với cờ --enable-source-maps (hoặc đặt NODE_OPTIONS=--enable-source-maps trong Dockerfile/PaaS):
Đã chạy thật trên Node 24 với output của tsc (sourceMap: true): không cờ thì stack trace là dist/boom.js, có cờ (hay NODE_OPTIONS) thì là src/boom.ts. Cờ này vẫn mặc định tắt ở các bản Node hiện hành. Dùng tsx khi dev thì không cần: tsx tự lo source map. Đừng bao giờ để --inspect mở trên server prod: cổng 9229 hở = toàn quyền thực thi code trên server.
12. Monorepo — khi nào cần, khi nào không#
Định nghĩa. Nhiều package trong một repo, chia sẻ code qua workspace (pnpm workspace, npm workspaces, Turborepo/Nx cho caching build).
Tại sao quan trọng. Lý do thật để dùng monorepo là chia sẻ type giữa các service: định nghĩa DTO một lần, cả API và worker/CLI dùng chung, đổi contract là compile lỗi ngay ở mọi nơi.
Ví dụ — cấu trúc thực dụng:
Pitfall. Dựng monorepo + Turborepo từ ngày đầu cho một service. Bạn trả toàn bộ chi phí (build phức tạp, Docker context rắc rối, IDE chậm, resolve type lỗi lạ) mà chưa có lợi ích nào. Quy tắc: bắt đầu single repo. Chỉ tách monorepo khi đã có service thứ hai thật sự cần dùng chung code.
Bài thực hành — dựng skeleton từ số 0#
Không copy template. Gõ tay từng bước, mỗi bước chạy thử:
Vì sao ghim typescript@6 (đã chạy npm 11.19.0 và pnpm 11.1.3): typescript-eslint 8.71.0 chỉ nhận typescript >=4.8.4 <6.1.0, còn latest là 7.0.2. Cài typescript và typescript-eslint cùng một lệnh thì npm tự chọn 6.0.3; cài TS 7 trước rồi thêm typescript-eslint thì npm dừng với ERESOLVE ... peer typescript@">=4.8.4 <6.1.0", còn pnpm chỉ in [WARN] Issues with peer dependencies found và vẫn cài. Lệnh pnpm tương ứng: pnpm add express zod, pnpm add -D typescript@6 .... Sau khi cài sẽ có cảnh báo install-scripts cho esbuild (do tsx): xem mục 8a. Đã chạy đúng lệnh trên (với tsdown) trong thư mục tạm: package.json ghi typescript ^6.0.3, tsdown ^0.23.0, express ^5.2.1, zod ^4.6.5.
Rồi lần lượt (mở đáp án dưới từng bước):
-
Sửa
package.json: thêm"type": "module","private": true,"engines", và các script ở mục 7.Lời giải và cách kiểm tra
Code tham chiếu, chưa chạy; dùng sơ đồ và ghi chú môi trường ở Khung và mã dùng chung.
Chép
package.jsonở mục 7 ("type": "module","private": true,engines, các script). Kết quả mong đợi:npm installkhông lỗi vànpm run typecheckchạy được script. Lỗi hay gặp: không có"type": "module"mà dùngimport(Node báo lỗi cú pháp module). -
Viết
tsconfig.jsontheo mục 2. Chạynpm run typecheck→ phải pass.Lời giải và cách kiểm tra
Chép
tsconfig.jsonở mục 2. Nếu dùng alias thì thêm"paths": { "@/*": ["./src/*"] }và không thêmbaseUrl(TS 6 báo TS5101, TS 7 báo TS5102). Kết quả mong đợi:npm run typecheckthoát với exit code 0 và không in gì. Lỗi hay gặp:"lib": ["DOM"]còn sót;importnội bộ thiếu đuôi.jslàmtscbáoTS2835. -
Viết
src/config/env.tstheo mục 10. Cố tình xoáDATABASE_URLkhỏi.env→ xác nhận process chết lúc boot với thông báo rõ ràng, không phải chết mơ hồ ở request đầu tiên.Lời giải và cách kiểm tra
typescriptReadyChạy với
node --env-file=.envđã bỏ dòngDATABASE_URL: mong đợi process inInvalid environment:kèm dòng chỉ ra trườngDATABASE_URLthiếu và thoát với code1trước khilisten. Khôi phục dòng đó thì chạy bình thường. Lỗi hay gặp:.envđặt sai thư mục (đường dẫn của--env-filetính từ thư mục chạy lệnh); đọcprocess.envở file khác (mục 10). -
Viết
src/main.tsvới 1 route Express.npm run dev→ sửa file → xác nhận nó tự restart.Lời giải và cách kiểm tra
typescriptReadynpm run dev, sửa một chuỗi trongmain.ts, lưu. Mong đợi: terminal in lạilistening on 3000(tsx khởi động lại process).curl localhost:3000/health/livetrả{"ok":true}. Lỗi hay gặp: không có"type": "module"nhưng dùngimport(Node báo lỗi cú pháp module). -
npm run build && npm start→ phải chạy được. Đây là bước hầu hết mọi người bỏ qua và bị vỡ ở prod.Lời giải và cách kiểm tra
bashReady(
npm startkhông nạp.env; trong CI hoặc container biến môi trường được inject sẵn, còn ở local thìexportchúng hoặc dùng lệnh trên.) Mong đợi:buildchạytsc --noEmitrồitsdown(cầntsdown.config.tsở mục 6, cófixedExtension: falseđể radist/main.js), sinhdist/main.jsvàdist/main.js.map; server lên và/health/livetrả 200. Đã chạy phần bundle trên skeleton rút gọn (không có Express):tsdownbuild radist/main.js,node --enable-source-maps dist/main.jsinhello world. Lỗi hay gặp: import thiếu.js(raERR_MODULE_NOT_FOUNDchỉ ở bản build); bundle dependency làm gãy native addon (mục 6). -
Thêm alias theo mục 4 → build lại → xác nhận
dist/vẫn chạy.Lời giải và cách kiểm tra
Cách khuyên dùng khi bundle bằng
tsdown: giữpathsnhư bước 2 (tsdown đọctsconfig.paths), viếtimport { env } from '@/config/env.js', build lại. Kiểm chứng bằng chứng cứ chứ không chỉ chạy thử:grep -c "@/" dist/main.jsphải ra0(alias đã được gộp) vànode dist/main.jschạy được. Đã chạy trên skeleton rút gọn vớipathslà@/*trỏ./src/*vàtsc --noEmitpass: tsdown chogrep -cra0và inhello world aliased. Đối chứng cho bẫy ở mục 4, cũng đã chạy: build bằngtscthuần (khôngtsc-alias) rồinode dist/main.jsraError [ERR_MODULE_NOT_FOUND]: Cannot find package '@/greet.js' imported from ...vì chuỗi@/...vẫn nằm nguyên trongdist/; thêmtsc-aliashoặc dùng subpath imports#...thì hết lỗi (hai cách này chưa chạy). -
Viết
eslint.config.jstheo mục 9. Cố tình quên mộtawait→ xác nhậnnpm run lintbắt được.Lời giải và cách kiểm tra
typescriptReadynpm run lint. Mong đợi: lỗi@typescript-eslint/no-floating-promises("Promises must be awaited, end with a call to .catch, ..."). Sửa thànhawait createUser()(hàmasync) hoặcvoid createUser()nếu chủ ý fire-and-forget (nhớ gắn.catch). Lỗi hay gặp:projectServicebáo file không thuộc tsconfig (file cấu hình nhưtsdown.config.tscần nằm trongincludehoặc bịignores). -
Đặt
debuggertrong route, chạy VSCode debug, hit endpoint → dừng đúng dòng trong file.ts(không phải.js) → source map đang hoạt động. Rồi cố tìnhthrowtrong route và chạynpm run build && npm start: stack trace phải trỏsrc/*.ts(nhờ--enable-source-mapsở scriptstart); bỏ cờ đi để thấy nó trỏdist/*.js.Lời giải và cách kiểm tra
bashReadyMong đợi: stack trace của
boomtrỏsrc/main.ts:<dòng>:<cột>; chạy lại bằngnode --env-file=.env dist/main.js(bỏ cờ) thì trỏdist/main.js:.... Với VS Code, dùnglaunch.jsonở mục 11, đặt breakpoint trong route,curlvào route: debugger phải dừng ở dòng trong file.ts(chưa chạy: cần VS Code). Nhớ tắt--inspectở production.
Khung và mã dùng chung
Tự làm trước, rồi mới mở. Code tham chiếu, chưa chạy: phần "mong đợi" của bài thực hành suy ra từ code và tài liệu. Các hành vi đã có ghi chú chạy thật ngay trong bài: paths so với baseUrl trên TS 6.0.3 và 7.0.2 (mục 4), --enable-source-maps trên Node 24 (mục 11), ba cờ isolatedModules/verbatimModuleSyntax/erasableSyntaxOnly (mục 2), build bằng tsdown, tsup và tsc (mục 6), và các lệnh npm/pnpm ở mục 8a cùng lệnh cài ở đầu bài thực hành. Phiên bản theo bảng kiểm chứng đầu file: Node 24, Express 5, zod 4, TypeScript 6 hoặc 7 (lint cần TS 6, xem mục 9).
Sơ đồ. Mỗi bước thêm một lớp, và mỗi lớp có một lệnh chứng minh nó đang hoạt động.
Tự kiểm tổng: npm run typecheck, npm run lint, npm run build && node --env-file=.env dist/main.js đều xanh từ một thư mục sạch (rm -rf node_modules dist && npm ci), và .nvmrc/engines khớp với Node bạn dùng.
Done khi#
-
Giải thích được khác biệt type-check vs transpile, và vì sao build bằng esbuild/tsup bắt buộc phải có bước
tsc --noEmitriêng.Đáp án
Type-check (
tsc --noEmit) kiểm tra kiểu và không sinh file; transpile (esbuild, swc, tsx, tsup) chỉ xoá type nên chạy được cả code sai kiểu. Build bằng tsup mà không cótsc --noEmitthì đã tắt TypeScript trong thực tế: lỗi kiểu vẫn lên production. Cách tự kiểm: cố ý gánstringchonumber;npm run buildphải đỏ nhờ bướctypecheckđứng trướctsup. Xem GĐ02 mục 1 (có sơ đồ) và mục 6. -
Đọc hiểu từng dòng
tsconfig.json; biết vì sao BE không để"lib": ["DOM"].Đáp án
target/libkhớp runtime,types: ["node"],module/moduleResolution: NodeNext,strictcộngnoUncheckedIndexedAccess,forceConsistentCasingInFileNames. Không có DOM vì Node không códocument/window; cóDOMthì code gọilocalStoragevẫn quatscrồi chết lúc chạy. Cách tự kiểm: thử gõdocument.titletrong file backend,tscphải báo lỗi. Xem mục 2. -
Xác định được một file là ESM hay CJS chỉ bằng cách nhìn
package.json+ đuôi file.Đáp án
Đuôi
.mjs/.cjsthắng;.jstheo"type"củapackage.jsongần nhất (modulelà ESM, còn lại là CJS);.mts/.ctstương ứng. Cách tự kiểm: dùng bảng ở khối "Kết quả mong đợi" của mục 3 để đoán, rồi chạynodexác nhận. Xem mục 3. -
Giải thích được vì sao trong ESM+TS phải viết
import './x.js'cho filex.ts.Đáp án
TypeScript không viết lại đường dẫn khi emit, nên specifier phải là tên file sau build. Với
module: NodeNext, bỏ đuôi thìtscbáo TS2835 và viết.tsthì báo TS5097 (trừ khi bậtallowImportingTsExtensionshoặcrewriteRelativeImportExtensions); khi bundle bằngtsup/esbuildhay chạy bằngtsxthì không lỗi lúc build và chỉ vỡ ở runtime vớiERR_MODULE_NOT_FOUND(chưa chạy lại). Cách tự kiểm: build rồi chạynode dist/main.js. Xem mục 3, Pitfall 1. -
Biết
__dirnamekhông tồn tại trong ESM và thay bằng gì.Đáp án
Không tồn tại (
ReferenceError); dùngimport.meta.dirnamevàimport.meta.filename(Node 20.11+), hoặcfileURLToPath(import.meta.url)cho bản cũ hơn. Xem mục 3. -
Cấu hình path alias và chứng minh
npm run build && npm startchạy được (không chỉnpm run dev).Đáp án
pathschỉ dạy TypeScript; muốn Node chạy được phải bundle (tsdown hoặc tsup), hoặc dùng subpath imports#..., hoặctsc-alias. Chứng minh:npm run build && node dist/main.jschạy được vàgrepkhông còn chuỗi alias trongdist/(bước 6 của bài thực hành). Không dùngbaseUrl(TS 7 đã gỡ). Sai thường gặp: chỉ thử bằngnpm run dev. Xem mục 4. -
Phân biệt
npm installvsnpm ci; biết vì sao Dockerfile luôn dùngnpm ci.Đáp án
installđược phép sửa lockfile;cixoánode_modulesvà cài đúng lockfile, lỗi nếu lockfile lệchpackage.json. Dockerfile và CI dùngnpm ciđể image giống bản đã test. Cách tự kiểm: sửa tay version trongpackage.jsonrồi chạynpm ci, nó phải thất bại. Xem mục 8. -
Có
eslint.config.jsbậtno-floating-promises; giải thích được bug mà rule này chặn.Đáp án
Rule chặn gọi hàm trả Promise mà không
await/.catch/void: lỗi bị nuốt thànhunhandledRejection(Node mặc định làm chết process) và code chạy tiếp trước khi việc xong. Cách tự kiểm: bước 7 của bài thực hành (npm run lintphải đỏ rồi xanh sau khi sửa). Xem mục 9. -
Validate
process.envbằng zod tại một file duy nhất, fail-fast lúc boot.Đáp án
config/env.tsparse bằng zod đúng một lần lúc boot, thiếu hoặc sai biến thì process thoát ngay với thông báo rõ; mọi nơi khácimport { env }. Cách tự kiểm: bước 3 của bài thực hành, vàgrep -rn "process.env" srcchỉ ra đúng fileenv.ts. Xem mục 10. -
Debug bằng VSCode, dừng đúng dòng trong file
.tsnhờ source map; giải thích được vì sao stack trace prod cần--enable-source-maps.Đáp án
Cần cả hai:
sourcemap: truekhi build và--enable-source-mapskhi chạy (NODE_OPTIONStrong container); thiếu một trong hai thì stack trace trỏdist/*.js. Cách tự kiểm: bước 8, stack phải rasrc/*.ts. Không mở--inspectở production. Xem mục 11. -
Nói được vì sao chưa dùng monorepo.
Đáp án
Monorepo đáng khi có service thứ hai thật sự cần chia sẻ code (DTO, schema); trước đó chi phí build, Docker context, IDE vượt lợi ích. Tự kiểm: liệt kê được "service thứ hai" cụ thể và đoạn code nó cần chia sẻ; nếu không liệt kê được thì chưa cần. Xem mục 12.
-
Giải thích được ba cờ
isolatedModules,verbatimModuleSyntax,erasableSyntaxOnlybắt lỗi gì, và vì sao dự án NestJS thường phải bỏerasableSyntaxOnly.Đáp án
Công cụ chỉ xoá type (tsx, esbuild, tsdown, Node) nhìn từng file riêng, nên
tscphải báo sớm những thứ chúng không chạy đúng:erasableSyntaxOnlycấm cú pháp phải sinh code (enum,namespace;TS1294);verbatimModuleSyntaxbắtimport type/export typecho type (TS1484,TS1205), nếu không Node némSyntaxError ... does not provide an export named 'User';isolatedModulesgiữ mỗi file biên dịch được một mình. Nest dùng parameter property nên vướngerasableSyntaxOnly(TS1294, đã chạy trên TS 6.0.3 và 7.0.2). Decorator thì cờ không báo, nhưng Node type stripping không chạy được (SyntaxError), nên Nest vẫn cần bước build. Cách tự kiểm: chép ba file lỗi trong khối "Bằng chứng đã chạy" ở mục 2, chạytsccó và không có ba cờ. -
Kể được các lớp hygiene chuỗi cung ứng của npm (lockfile +
npm ci, duyệt install script,npm audit signatures,min-release-age) và vì sao bài thực hành ghimtypescript@6.Đáp án
Bốn lớp ở bảng mục 8a, mỗi lớp một lệnh kiểm:
npm cithất bại khi lockfile lệch;npm install-scripts lscho biết package nào chưa được duyệt chạy script;npm audit signaturesxác nhận chữ ký registry;--min-release-age=<ngày>chặn bản quá mới.npm auditmột mình không thay thế được các lớp này. Ghimtypescript@6vì typescript-eslint 8.71.0 chỉ nhậntypescriptdưới 6.1.0 trong khilatestlà 7.0.2: cài TS 7 trước rồi thêm typescript-eslint choERESOLVE. Số phiên bản đổi theo thời gian, kiểm lạinpm view typescript-eslint peerDependenciestrước khi áp dụng. Xem mục 8a.
Câu hỏi mở / chưa giải quyết#
-
ESM hay CJS cho project chính? Nest 12 là ESM-only nên không còn lý do kéo về CJS; câu hỏi còn lại là các thư viện cũ chỉ có CJS.
Hướng trả lời hiện tại
Chưa chốt, suy ra từ bảng kiểm chứng đầu file: chọn ESM cho project mới; Nest 12 cũng ESM-only. Thư viện cũ chỉ có CJS vẫn
importđược từ ESM (default import). -
tsdown(kế nhiệmtsup;tsupkhông còn được bảo trì chủ động) vstscthuần (chuẩn, ít bất ngờ): với NestJS cứ đểnest build; với Express thìtsdownđáng thử.Hướng trả lời hiện tại
Chưa chốt: với Express,
tscthuần (kèmrewriteRelativeImportExtensions) là mặc định ít bất ngờ nhất;tsdownđáng thử khi cần bundle (đã chạy bản 0.23.0, còn nhánh 0.x nên ghim version;tsupkhông còn được bảo trì chủ động). Dù chọn gì, giữ bướctsc --noEmitriêng. -
Native Node type-stripping (bật mặc định, không cờ) chạy được
.tsđơn giản — nhưng chưa thay đượctsxkhi cần decorator (NestJS) hoặcenum/namespace(chỉerasableSyntaxOnlymới giữ code tương thích).Hướng trả lời hiện tại
Chưa chốt: type stripping gốc của Node đủ cho script và code đơn giản; vẫn cần
tsxhoặc bước build khi dùngenum,namespacehay decorator. BậterasableSyntaxOnlyđểtscbáo sớm cú pháp mà Node không chạy được. -
Test runner:
vitest(nhanh, config gần như bằng 0, ESM-native) vsjest(hệ sinh thái lớn; Nest 12 mặc định là Vitest). Chốt ở GĐ13.Hướng trả lời hiện tại
Chưa chốt (chốt ở GĐ13): Vitest là hướng hiện tại, Nest 12 cũng mặc định Vitest.