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, __dirname undefined) đề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 .ts bằng type stripping mặc định, không còn cờ --experimental-strip-types. --enable-source-maps vẫn mặc định tắt. TypeScript latest là 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ỏ sang tsdown (README tsup, hướng dẫn chuyển). tsdown cò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 --version ra 0.36.0 trên Node 24.21.0).
  • npm 11.19.0 (đã chạy): cảnh báo install-scripts kèm lệnh npm install-scripts ls|approve|deny, khoá allowScripts trong package.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 latest trê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
textReady
 dev       src/*.ts --tsx/esbuild (xoá type, KHÔNG kiểm tra)--> process Node CI        src/*.ts --tsc --noEmit (kiểm tra, không sinh file)--> pass/fail build     src/*.ts --tsdown/esbuild (xoá type, gộp file)--> dist/main.js           (hoặc tsc: kiểm tra + sinh dist/*.js) prod      node --enable-source-maps dist/main.js

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:

jsonReady
{  "compilerOptions": {    // --- Nhắm runtime ---    "target": "ES2023",              // Node 22 hỗ trợ; đừng để ES5 (downlevel vô ích, code xấu, chậm)    "lib": ["ES2023"],               // KHÔNG có "DOM" — không có document/window/localStorage ở BE    "types": ["node"],               // chỉ nạp @types/node, chặn type global rác lọt vào    // --- Module system (mục 3) ---    "module": "NodeNext",            // Node quyết định ESM/CJS theo package.json + đuôi file    "moduleResolution": "NodeNext",  // resolve đúng như Node thật, kể cả "exports" field    // --- Output ---    "rootDir": "src",    "outDir": "dist",    "sourceMap": true,               // sinh file .map; Node chỉ dùng nó khi chạy với --enable-source-maps (mục 11)    "declaration": false,            // true chỉ khi publish package/dùng monorepo    // --- Strictness (mục 10) ---    "strict": true,    "noUncheckedIndexedAccess": true,  // arr[0] có kiểu T | undefined — ép bạn check    "exactOptionalPropertyTypes": true,    "noImplicitOverride": true,    "noFallthroughCasesInSwitch": true,    // --- Interop & tốc độ ---    "esModuleInterop": true,    "forceConsistentCasingInFileNames": true,  // macOS không phân biệt hoa/thường, Linux CÓ    "skipLibCheck": true,            // bỏ type-check node_modules — nhanh hơn nhiều, an toàn    "incremental": true,    // --- Code phải chạy được khi chỉ bị xoá type (mục 5, 6; giải thích sau mục này) ---    "isolatedModules": true,         // mỗi file phải biên dịch được một mình, như esbuild/tsx/Node làm    "verbatimModuleSyntax": true,    // import/export giữ nguyên; type phải đi qua `import type`    "erasableSyntaxOnly": true       // cấm enum/namespace/parameter property (bỏ khi dùng NestJS)  },  "include": ["src/**/*"],  "exclude": ["node_modules", "dist"]}

Cơ chế của 2 dòng hay bị bỏ qua.

  • noUncheckedIndexedAccess: mặc định TS nói users[999] có kiểu User — nói dối. Bật lên thành User | undefined. Đây là nguồn Cannot read property 'x' of undefined phổ 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ên userService.ts vẫ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)
erasableSyntaxOnlycú pháp phải sinh code JS: enum, namespace, parameter property (constructor(private x: T))TS1294
verbatimModuleSyntaximport { User } khi User chỉ là type; export { User } from một typeTS1484, TS1205
isolatedModulescú pháp mà một file đứng riêng không biên dịch đúng đượckhô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'.

textReady
 tsc -p tsconfig.base.json            (không có ba cờ)   -> exit 0, không in gì: ba file lỗi vẫn "pass" tsc -p tsconfig.strictmodules.json   (có ba cờ)   -> exit 2   src/enum.ts(1,13): error TS1294: This syntax is not allowed when       'erasableSyntaxOnly' is enabled.   src/import-type.ts(1,10): error TS1484: 'User' is a type and must be       imported using a type-only import when 'verbatimModuleSyntax' is       enabled.   src/reexport.ts(1,10): error TS1205: Re-exporting a type when       'verbatimModuleSyntax' is enabled requires using 'export type'.

Hậu quả khi bỏ qua, chạy thẳng bằng Node (type stripping):

textReady
 node bad.ts    (import { User, defaultId } from './types.ts')   -> SyntaxError: The requested module './types.ts' does not provide      an export named 'User' node good.ts   (import { type User, defaultId } from './types.ts')   -> { id: 'u1' } node enum.ts   (enum Role { A })   -> code: 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'

Đã 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ệnKết quả
package.json có "type": "module".js = ESM
Không có "type" (hoặc "type": "commonjs").js = CJS
Đuôi .mjsluôn ESM (bất kể type)
Đuôi .cjsluôn CJS
.ts → .mts / .ctstươ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ép require() 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ớ:

typescriptReady
// === ESM ===import { readFile } from 'node:fs/promises';import path from 'node:path';// __dirname và __filename KHÔNG TỒN TẠI trong ESMconst __dirname = import.meta.dirname;          // Node 20.11+ / 21.2+// bản cũ hơn: path.dirname(fileURLToPath(import.meta.url))// import PHẢI có đuôi file khi trỏ file nội bộimport { userService } from './user.service.js';  // .js — KHÔNG phải .ts (xem pitfall)// top-level await hoạt độngconst config = await loadConfig();// === CJS ===const { readFile } = require('node:fs/promises');const __dirname_is_free = __dirname;             // có sẵnconst userService = require('./user.service');   // không cần đuôi// top-level await KHÔNG dùng được

Pitfall — hai cái đau nhất:

  1. Viết .ts nhưng import phải ghi .js. Với module: NodeNext + ESM, bạn có file user.service.ts nhưng phải viết import ... 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 crash MODULE_NOT_FOUND ở runtime, mà tsc không hề báo lỗi lúc build.

  2. Package ESM-only. Nhiều package hiện đại (chalk v5+, node-fetch v3+, nanoid v4+) bỏ CJS hoàn toàn. Project CJS require('chalk') → nổ. Cách xử: dùng await 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.jsonTên fileLoạiGhi chú
"type": "module"app.jsESMimport, có import.meta.dirname
không có typeapp.jsCJSrequire, có __dirname
bất kỳapp.mjs / app.cjsESM / CJSđuôi thắng type
"type": "module"legacy.cjsCJSfile đơn lẻ vẫn là CJS
"type": "module"app.mtsESM (TypeScript)emit ra .mjs

Lỗi hay gặp, chạy được trong vài giây để tự thấy:

textReady
 ESM:  console.log(__dirname)       -> ReferenceError: __dirname is not defined in ES module scope ESM:  import { x } from './user.service'         (thiếu đuôi)       -> Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../user.service' ESM:  import { x } from './user.service.ts'  trong file build bằng tsc/tsup       -> sai: dist chỉ có .js; viết './user.service.js'          (tsc báo TS5097, tsup/esbuild thì không báo) CJS:  require('chalk')  với chalk ESM-only trên Node cũ       -> Error [ERR_REQUIRE_ESM]. Node 22.12+ cho require() ESM không có          top-level await, nên lỗi này tuỳ phiên bản Node; await import() thì          chạy ở mọi bản.

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:

jsonReady
// tsconfig.json — chỉ là bước 1, chưa đủ// KHÔNG dùng "baseUrl": TS 6 báo lỗi TS5101 (deprecated), TS 7 gỡ hẳn (TS5102).// "paths" tự nó đã đủ, miễn mỗi đích bắt đầu bằng "./" (tính từ thư mục chứa tsconfig.json){ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }

Đã 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.

jsonReady
// CÁCH 1 (khuyên dùng cho ESM, không cần tool): Node subpath imports// package.json — Node hiểu native, không cần plugin gì{  "imports": { "#config/*": "./dist/config/*", "#services/*": "./dist/services/*" }}// dùng: import { env } from '#config/env.js'   // chú ý tiền tố # là bắt buộc
jsonReady
// CÁCH 2: bundle lúc build — alias biến mất vì mọi thứ gộp thành 1 file// tsdown (đã chạy, mục 6) và esbuild tự resolve tsconfig.paths
bashReady
# CÁCH 3: rewrite sau khi tsc emit (dự án đã lỡ dùng @/ khắp nơi)npm i -D tsc-aliastsc && tsc-alias        # tsc-alias sửa lại đường dẫn trong dist/

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áchLệnhGhi chú
tsx (khuyên dùng)tsx watch src/main.tsesbuild bên dưới, rất nhanh, hiểu tsconfig.paths, chạy cả ESM/CJS
Node nativenode --watch src/main.tsType 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 + nodemonnodemon --exec ts-node src/main.tsCũ, chậm hơn (type-check mỗi lần restart), còn gặp nhiều ở dự án legacy
NestJSnest start --watchNest lo sẵn, đừng tự chế

Ví dụ scripts:

jsonReady
{  "scripts": {    "dev": "tsx watch --env-file=.env src/main.ts",    "typecheck": "tsc --noEmit"  }}

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
tscCóChậmMặc định an toàn. NestJS dùng cái này
tsdown (rolldown)KhôngRất nhanhApp/lib cần bundle; kế nhiệm tsup, còn ở nhánh 0.x nên ghim version
tsup (esbuild)KhôngRất nhanhCò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
swcKhôngRất nhanhThay 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):

typescriptReady
// tsdown.config.tsimport { defineConfig } from 'tsdown'export default defineConfig({  entry: ['src/main.ts'],  format: ['esm'],  target: 'node24',  platform: 'node',  sourcemap: true,        // BẮT BUỘC, và chạy node với --enable-source-maps (mục 11)  clean: true,  dts: false,             // app, không phải thư viện  fixedExtension: false,  // giữ dist/main.js; mặc định tsdown ra dist/main.mjs});

Ba điều đã thấy khi chạy:

  • Mặc định (không có fixedExtension: false) đầu ra là dist/main.mjs, nên script start phải trỏ .mjs hoặc đặt cờ như trên.
  • dependencies và peerDependencies trong package.json mặc định là external: dist/main.js mở đầu bằng import { z } from "zod", không nhúng code zod. 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.

typescriptReady
// tsup.config.tsimport { defineConfig } from 'tsup'export default defineConfig({  entry: ['src/main.ts'],  format: ['esm'],  target: 'node24',  sourcemap: true,  clean: true,  skipNodeModulesBundle: true,  // không bundle native module (bcrypt, sharp)})
jsonReady
{  "scripts": {    "build": "npm run typecheck && tsdown",   // type-check TÁCH RIÊNG vì tsdown không làm    "start": "node --enable-source-maps dist/main.js"   // không có cờ này, stack trace trỏ vào dist/*.js  }}

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 đủ:

jsonReady
{  "name": "my-api",  "version": "1.0.0",  "type": "module",  "private": true,                       // chặn `npm publish` nhầm lên registry công khai  "engines": { "node": ">=24.0.0" },     // cảnh báo khi ai đó dùng Node cũ hơn  "packageManager": "npm@11.19.0",       // ghi đúng package manager đang dùng; pnpm thì "pnpm@<kết quả của pnpm -v>"  "scripts": {    "dev": "tsx watch --env-file=.env src/main.ts",    "build": "npm run typecheck && tsdown",    "start": "node --enable-source-maps dist/main.js",    "typecheck": "tsc --noEmit",    "lint": "eslint .",    "format": "prettier --write .",    "test": "vitest run",    "test:watch": "vitest",    "db:migrate": "prisma migrate deploy",    "db:studio": "prisma studio"  }}

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ạy corepack --version ra 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 đúng 24) — nvm use tự chọn version; GitHub Actions setup-node đọc được qua node-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ệch package.json. Dùng ở CI và Docker build, luôn luôn.
  • pnpm install --frozen-lockfile — tương đương npm ci của pnpm (đã chạy pnpm 11.1.3: lockfile khớp thì in Already up to date); pnpm add thay cho npm 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 trong package.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ớpViệc cần làmĐã thấy khi chạy
Lockfile + npm cicommit 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 scriptduyệ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=truecà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ốckiểm sau khi càinpm 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):

  1. Trong thư mục tạm, npm init -y, npm i -D typescript@6, giữ nguyên package-lock.json. Sửa package.json thành "typescript": "^5.9.0" rồi chạy npm ci. Sau đó khôi phục package.json về ^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 ci phải thất bại với EUSAGE và dòng Invalid: lock file's typescript@6.0.3 does not satisfy typescript@5.9.3 (lockfile lệch package.json, npm ci không tự sửa). Đã chạy trên npm 11.19.0: ở thư mục này, --dry-run khi chưa khôi phục package.json in change typescript 6.0.3 => 5.9.3, còn sau khi khôi phục in up to date (lockfile đã có 6.0.3, không có gì để thêm). Chỉ trong thư mục mới, rỗng, --dry-run với --min-release-age=120 mới in add 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ục package.json trước khi chạy tiếp; chạy npm install thay cho npm ci rồ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):

typescriptReady
import js from '@eslint/js';import { defineConfig } from 'eslint/config';import tseslint from 'typescript-eslint';export default defineConfig(  { ignores: ['dist/**', 'coverage/**', '*.config.js'] },  js.configs.recommended,  // typeChecked: chậm hơn nhưng mở khoá các rule cần thông tin kiểu — đáng ở BE  tseslint.configs.recommendedTypeChecked,  {    languageOptions: {      parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },    },    rules: {      '@typescript-eslint/no-floating-promises': 'error',   // quên await → lỗi im lặng      '@typescript-eslint/no-misused-promises': 'error',      '@typescript-eslint/no-explicit-any': 'warn',      'no-console': ['warn', { allow: ['error'] }],          // ép dùng logger (GĐ09)    },  },);

Cơ chế no-floating-promises — vì sao nó đáng giá nhất:

typescriptReady
// BUG: quên await. Request trả 200 ngay, nhưng email chưa gửi và nếu lỗi thì// process nhận unhandledRejection — Node 15+ mặc định CRASH cả server.app.post('/signup', async (req, res) => {  createUser(req.body);          // ← ESLint bắt được dòng này  res.status(201).json({ ok: true });});

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:

typescriptReady
// SAI: `as` là ép kiểu, KHÔNG kiểm tra gì. Runtime body có thể là bất cứ thứ gì.app.post('/users', (req, res) => {  const body = req.body as { email: string; age: number };  sendEmail(body.email.toLowerCase());   // body.email = undefined → crash 500});// ĐÚNG: parse + validate ở biên, sau đó type mới có thậtimport { z } from 'zod';const CreateUser = z.object({ email: z.email(), age: z.number().int().min(0) });app.post('/users', (req, res) => {  const parsed = CreateUser.safeParse(req.body);  if (!parsed.success) return res.status(400).json({ errors: parsed.error.issues });  const body = parsed.data;              // kiểu suy ra TỪ validator — đã được kiểm chứng});

Ví dụ — env cũng là biên:

typescriptReady
// src/config/env.ts — validate MỘT LẦN lúc boot, fail fastimport { z } from 'zod';const schema = z.object({  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),  PORT: z.coerce.number().default(3000),      // process.env luôn là string → coerce  DATABASE_URL: z.url(),  JWT_SECRET: z.string().min(32),});// Sai env → process chết NGAY lúc boot, kèm thông báo rõ.// Tốt hơn nhiều so với chết lúc 2h sáng ở request thứ 10.000.export const env = schema.parse(process.env);

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.

textReady
 POST /users  {}                        (ví dụ dùng `as`)   -> TypeError: Cannot read properties of undefined (reading 'toLowerCase')   -> 500: lỗi của CLIENT bị báo thành lỗi của SERVER, và chỉ lộ khi chạy. POST /users  {}                        (ví dụ dùng zod)   -> 400 { errors: [{ path: ['email'],        message: 'Invalid input: expected string, received undefined' }, ...] } POST /users  {"email":"x","age":-1}   -> 400, hai issue: email không hợp lệ, age nhỏ hơn 0 POST /users  {"email":"a@b.co","age":3}   -> qua; `parsed.data` có kiểu { email: string; age: number } do zod suy ra

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ế.

bashReady
# Mở inspector, Chrome vào chrome://inspect là attach đượcnode --inspect dist/main.js# Dừng ngay dòng đầu — để debug lỗi lúc bootnode --inspect-brk dist/main.js# Dev với tsx + inspectortsx watch --inspect src/main.ts

Ví dụ — .vscode/launch.json:

jsonReady
{  "version": "0.2.0",  "configurations": [{    "type": "node",    "request": "launch",    "name": "Debug API",    "runtimeExecutable": "tsx",    "args": ["watch", "src/main.ts"],    "envFile": "${workspaceFolder}/.env",    "console": "integratedTerminal",    "skipFiles": ["<node_internals>/**"]   // không nhảy vào code lõi Node khi step  }]}

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):

bashReady
node dist/main.js                       # trỏ vào dist/boom.js:2:11node --enable-source-maps dist/main.js  # trỏ vào src/boom.ts:2:9NODE_OPTIONS=--enable-source-maps node dist/main.js   # tương đương, tiện cho container

Đã 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:

textReady
apps/  api/         # NestJS/Express  worker/      # BullMQ consumer — dùng chung DB layer với apipackages/  shared/      # zod schema + type dùng chung  db/          # Prisma schema + clientpnpm-workspace.yaml

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ử:

bashReady
mkdir api && cd api && npm init -ynpm i express zodnpm i -D typescript@6 tsx @types/node @types/express \        eslint @eslint/js typescript-eslint prettier tsdownnpx tsc --init

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):

  1. 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 install không lỗi và npm run typecheck chạy được script. Lỗi hay gặp: không có "type": "module" mà dùng import (Node báo lỗi cú pháp module).

  2. Viết tsconfig.json theo mục 2. Chạy npm 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êm baseUrl (TS 6 báo TS5101, TS 7 báo TS5102). Kết quả mong đợi: npm run typecheck thoát với exit code 0 và không in gì. Lỗi hay gặp: "lib": ["DOM"] còn sót; import nội bộ thiếu đuôi .js làm tsc báo TS2835.

  3. Viết src/config/env.ts theo mục 10. Cố tình xoá DATABASE_URL khỏ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
    typescriptReady
    import { z } from 'zod'const schema = z.object({  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),  PORT: z.coerce.number().int().default(3000),  DATABASE_URL: z.url(),  JWT_SECRET: z.string().min(32),})const parsed = schema.safeParse(process.env)if (!parsed.success) {  console.error(`Invalid environment:\n${z.prettifyError(parsed.error)}`)  process.exit(1)}export const env = parsed.data

    Chạy với node --env-file=.env đã bỏ dòng DATABASE_URL: mong đợi process in Invalid environment: kèm dòng chỉ ra trường DATABASE_URL thiếu và thoát với code 1 trước khi listen. 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-file tính từ thư mục chạy lệnh); đọc process.env ở file khác (mục 10).

  4. Viết src/main.ts vớ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
    typescriptReady
    import express from 'express'import { env } from './config/env.js'const app = express()app.get('/health/live', (_req, res) => res.json({ ok: true }))app.get('/boom', () => {  throw new Error('boom') // Express 5: lỗi đồng bộ và async handler đều chuyển sang error handler})app.listen(env.PORT, () => console.log(`listening on ${env.PORT}`))

    npm run dev, sửa một chuỗi trong main.ts, lưu. Mong đợi: terminal in lại listening on 3000 (tsx khởi động lại process). curl localhost:3000/health/live trả {"ok":true}. Lỗi hay gặp: không có "type": "module" nhưng dùng import (Node báo lỗi cú pháp module).

  5. 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 run build && node --env-file=.env --enable-source-maps dist/main.js

    (npm start không nạp .env; trong CI hoặc container biến môi trường được inject sẵn, còn ở local thì export chúng hoặc dùng lệnh trên.) Mong đợi: build chạy tsc --noEmit rồi tsdown (cần tsdown.config.ts ở mục 6, có fixedExtension: false để ra dist/main.js), sinh dist/main.js và dist/main.js.map; server lên và /health/live trả 200. Đã chạy phần bundle trên skeleton rút gọn (không có Express): tsdown build ra dist/main.js, node --enable-source-maps dist/main.js in hello world. Lỗi hay gặp: import thiếu .js (ra ERR_MODULE_NOT_FOUND chỉ ở bản build); bundle dependency làm gãy native addon (mục 6).

  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ữ paths như bước 2 (tsdown đọc tsconfig.paths), viết import { 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.js phải ra 0 (alias đã được gộp) và node dist/main.js chạy được. Đã chạy trên skeleton rút gọn với paths là @/* trỏ ./src/* và tsc --noEmit pass: tsdown cho grep -c ra 0 và in hello world aliased. Đối chứng cho bẫy ở mục 4, cũng đã chạy: build bằng tsc thuần (không tsc-alias) rồi node dist/main.js ra Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@/greet.js' imported from ... vì chuỗi @/... vẫn nằm nguyên trong dist/; thêm tsc-alias hoặc dùng subpath imports #... thì hết lỗi (hai cách này chưa chạy).

  7. Viết eslint.config.js theo mục 9. Cố tình quên một await → xác nhận npm run lint bắt được.

    Lời giải và cách kiểm tra
    typescriptReady
    // src/signup.ts: cố tình saiexport async function createUser(): Promise<void> {}export function signup(): void {  createUser() // thiếu await}

    npm run lint. Mong đợi: lỗi @typescript-eslint/no-floating-promises ("Promises must be awaited, end with a call to .catch, ..."). Sửa thành await createUser() (hàm async) hoặc void createUser() nếu chủ ý fire-and-forget (nhớ gắn .catch). Lỗi hay gặp: projectService báo file không thuộc tsconfig (file cấu hình như tsdown.config.ts cần nằm trong include hoặc bị ignores).

  8. Đặt debugger trong 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ình throw trong route và chạy npm run build && npm start: stack trace phải trỏ src/*.ts (nhờ --enable-source-maps ở script start); bỏ cờ đi để thấy nó trỏ dist/*.js.

    Lời giải và cách kiểm tra
    bashReady
    npm run build && node --env-file=.env --enable-source-maps dist/main.js &curl -s localhost:3000/boom        # lỗi in ra log server

    Mong đợi: stack trace của boom trỏ src/main.ts:<dòng>:<cột>; chạy lại bằng node --env-file=.env dist/main.js (bỏ cờ) thì trỏ dist/main.js:.... Với VS Code, dùng launch.json ở mục 11, đặt breakpoint trong route, curl và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.

textReady
 package.json ─► tsconfig ─► env.ts ─► main.ts ─► dev (tsx watch)   (bước 1)      (bước 2)   (bước 3)  (bước 4)    (bước 4)                                          │        build + start (bước 5) ◄──── alias (bước 6)                │        lint (bước 7)   debug + source map (bước 8)

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 --noEmit riê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 --noEmit thì đã tắt TypeScript trong thực tế: lỗi kiểu vẫn lên production. Cách tự kiểm: cố ý gán string cho number; npm run build phải đỏ nhờ bước typecheck đứng trước tsup. 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/lib khớp runtime, types: ["node"], module/moduleResolution: NodeNext, strict cộng noUncheckedIndexedAccess, forceConsistentCasingInFileNames. Không có DOM vì Node không có document/window; có DOM thì code gọi localStorage vẫn qua tsc rồi chết lúc chạy. Cách tự kiểm: thử gõ document.title trong file backend, tsc phả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/.cjs thắng; .js theo "type" của package.json gần nhất (module là ESM, còn lại là CJS); .mts/.cts tươ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ạy node xá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 file x.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ì tsc báo TS2835 và viết .ts thì báo TS5097 (trừ khi bật allowImportingTsExtensions hoặc rewriteRelativeImportExtensions); khi bundle bằng tsup/esbuild hay chạy bằng tsx thì không lỗi lúc build và chỉ vỡ ở runtime với ERR_MODULE_NOT_FOUND (chưa chạy lại). Cách tự kiểm: build rồi chạy node dist/main.js. Xem mục 3, Pitfall 1.

  • Biết __dirname không tồn tại trong ESM và thay bằng gì.

    Đáp án

    Không tồn tại (ReferenceError); dùng import.meta.dirname và import.meta.filename (Node 20.11+), hoặc fileURLToPath(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 start chạy được (không chỉ npm run dev).

    Đáp án

    paths chỉ dạy TypeScript; muốn Node chạy được phải bundle (tsdown hoặc tsup), hoặc dùng subpath imports #..., hoặc tsc-alias. Chứng minh: npm run build && node dist/main.js chạy được và grep không còn chuỗi alias trong dist/ (bước 6 của bài thực hành). Không dùng baseUrl (TS 7 đã gỡ). Sai thường gặp: chỉ thử bằng npm run dev. Xem mục 4.

  • Phân biệt npm install vs npm ci; biết vì sao Dockerfile luôn dùng npm ci.

    Đáp án

    install được phép sửa lockfile; ci xoá node_modules và cài đúng lockfile, lỗi nếu lockfile lệch package.json. Dockerfile và CI dùng npm ci để image giống bản đã test. Cách tự kiểm: sửa tay version trong package.json rồi chạy npm ci, nó phải thất bại. Xem mục 8.

  • Có eslint.config.js bật no-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ành unhandledRejection (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 lint phải đỏ rồi xanh sau khi sửa). Xem mục 9.

  • Validate process.env bằng zod tại một file duy nhất, fail-fast lúc boot.

    Đáp án

    config/env.ts parse 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ác import { env }. Cách tự kiểm: bước 3 của bài thực hành, và grep -rn "process.env" src chỉ ra đúng file env.ts. Xem mục 10.

  • Debug bằng VSCode, dừng đúng dòng trong file .ts nhờ source map; giải thích được vì sao stack trace prod cần --enable-source-maps.

    Đáp án

    Cần cả hai: sourcemap: true khi build và --enable-source-maps khi chạy (NODE_OPTIONS trong container); thiếu một trong hai thì stack trace trỏ dist/*.js. Cách tự kiểm: bước 8, stack phải ra src/*.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, erasableSyntaxOnly bắ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 tsc phải báo sớm những thứ chúng không chạy đúng: erasableSyntaxOnly cấm cú pháp phải sinh code (enum, namespace; TS1294); verbatimModuleSyntax bắt import type/export type cho type (TS1484, TS1205), nếu không Node ném SyntaxError ... does not provide an export named 'User'; isolatedModules giữ mỗi file biên dịch được một mình. Nest dùng parameter property nên vướng erasableSyntaxOnly (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ạy tsc có 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 ghim typescript@6.

    Đáp án

    Bốn lớp ở bảng mục 8a, mỗi lớp một lệnh kiểm: npm ci thất bại khi lockfile lệch; npm install-scripts ls cho biết package nào chưa được duyệt chạy script; npm audit signatures xác nhận chữ ký registry; --min-release-age=<ngày> chặn bản quá mới. npm audit một mình không thay thế được các lớp này. Ghim typescript@6 vì typescript-eslint 8.71.0 chỉ nhận typescript dưới 6.1.0 trong khi latest là 7.0.2: cài TS 7 trước rồi thêm typescript-eslint cho ERESOLVE. Số phiên bản đổi theo thời gian, kiểm lại npm view typescript-eslint peerDependencies trướ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ệm tsup; tsup không còn được bảo trì chủ động) vs tsc thuầ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, tsc thuần (kèm rewriteRelativeImportExtensions) 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; tsup không còn được bảo trì chủ động). Dù chọn gì, giữ bước tsc --noEmit riê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 được tsx khi cần decorator (NestJS) hoặc enum/namespace (chỉ erasableSyntaxOnly mớ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 tsx hoặc bước build khi dùng enum, namespace hay decorator. Bật erasableSyntaxOnly để tsc bá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) vs jest (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.