TypeScript: Thiết Lập Project cho Node.js Backend

Bạn đã biết TypeScript. Bạn đã build frontend với nó. Giờ bạn muốn build backend — một API thực sự, không phải project chơi chơi. Nhưng rào cản đầu tiên không phải viết business logic. Mà là thiết lập project cho đúng.
Một setup tệ sẽ ám ảnh bạn hàng tháng trời: build chậm, lỗi import bí ẩn, không auto-format, và cái tsconfig.json copy từ Stack Overflow mà chẳng ai hiểu. Một setup tốt thì invisible — nó just works, bắt bug sớm, và để bạn focus vào chuyện quan trọng.
Hướng dẫn này sẽ walk through từ đầu một TypeScript backend setup production-ready. Mọi quyết định đều được giải thích để bạn thực sự hiểu configuration của chính mình.
Thời gian: 1-2 giờ
Điều kiện: TypeScript Giai Đoạn 1: Nền Tảng và kinh nghiệm Node.js cơ bản
Những Gì Bạn Sẽ Học
✅ Khởi tạo TypeScript project cho Node.js từ đầu
✅ Cấu hình tsconfig.json với mọi option được giải thích
✅ Thiết lập ESLint 9 + Prettier cho code nhất quán
✅ Development workflow với tsx (chạy TypeScript siêu nhanh)
✅ Build process với tsc, esbuild, và swc
✅ Best practices cấu trúc project cho backend scalable
✅ Environment variables với type-safe validation
✅ Path aliases hoạt động cả trong dev lẫn production
1. Khởi Tạo Project
Bắt đầu từ đầu:
mkdir my-api
cd my-api
npm init -yCài TypeScript và Node.js types:
npm install -D typescript @types/nodeTại sao cần
@types/node? Node.js được viết bằng C++, không phải TypeScript. Package@types/nodecung cấp TypeScript type definitions cho tất cả built-in modules của Node.js (fs,path,http,process, v.v.). Không có nó, TypeScript không biếtprocess.envhayBufferlà gì.
Tạo tsconfig.json:
npx tsc --initLệnh này tạo file với mọi option bị comment out. Hãy thay nó bằng thứ chúng ta thực sự hiểu.
2. TypeScript Configuration (tsconfig.json)
Đây là configuration production-ready cho Node.js backend. Mọi option đều được giải thích:
{
"compilerOptions": {
// === Output ===
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
// === Strictness ===
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
// === Module Features ===
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
// === Quality of Life ===
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true,
// === Path Aliases ===
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}Giải Thích Các Option Quan Trọng
target: "ES2022" — JavaScript version để compile ra. ES2022 cho bạn top-level await, Array.at(), Object.hasOwn(), và class fields. Node.js 18+ support tất cả ES2022 features natively.
module: "NodeNext" + moduleResolution: "NodeNext" — Cặp này báo TypeScript theo cách resolve module native của Node.js. Support cả ESM (import) và CJS (require) dựa vào field "type" trong package.json. Đây là setting recommended cho Node.js projects từ 2025 trở đi.
strict: true — Bật tất cả strict type-checking options cùng lúc. Non-negotiable cho professional code. Bao gồm strictNullChecks, strictFunctionTypes, strictBindCallApply, v.v.
noUncheckedIndexedAccess: true — Viên ngọc ẩn. Khi bạn access array element hay object property bằng index, kết quả bao gồm undefined:
const items = ["a", "b", "c"];
const first = items[0]; // string | undefined (không chỉ string)
// Buộc bạn handle edge case
if (first !== undefined) {
console.log(first.toUpperCase()); // safe
}esModuleInterop: true — Cho phép import express from 'express' thay vì import * as express from 'express'. Hầu hết npm packages là CommonJS, flag này giúp chúng hoạt động clean với ES import syntax.
resolveJsonModule: true — Cho phép import config from './config.json' với full type inference. TypeScript đọc JSON structure và cung cấp typed access.
isolatedModules: true — Đảm bảo mỗi file có thể transpile độc lập. Bắt buộc nếu bạn dùng esbuild, swc, hoặc bất kỳ tool nào compile files riêng lẻ (hầu hết các tools đều vậy).
skipLibCheck: true — Bỏ qua type-checking cho .d.ts files từ node_modules. Tăng tốc compilation đáng kể. Gần như lúc nào cũng nên bật — nếu types của library bị lỗi, đó là problem của họ.
ESM vs CJS: Chọn Cái Nào?
Thêm vào package.json cho ESM:
{
"type": "module"
}ESM (recommended):
- Top-level
await import/exportsyntax- Tree-shakeable
- Standard cho tương lai
CJS (legacy):
require()/module.exports- Tương thích rộng hơn với packages cũ
- Không có top-level
await
Lời khuyên thực tế: Dùng ESM cho project mới. Nếu gặp compatibility issues với package cũ, bạn có thể dynamically
import()nó hoặc dùng compatibility wrapper.
3. ESLint 9 + Prettier Setup
Cài Dependencies
# ESLint 9 với TypeScript support
npm install -D eslint @eslint/js typescript-eslint
# Prettier
npm install -D prettier eslint-config-prettierESLint Configuration (eslint.config.mjs)
ESLint 9 dùng flat config format mới:
// eslint.config.mjs
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintConfigPrettier,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Rules thực tế cho backend development
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/prefer-nullish-coalescing": "warn",
"@typescript-eslint/prefer-optional-chain": "warn",
},
},
{
ignores: ["dist/", "node_modules/", "*.js"],
}
);Tại Sao Các Rules Này Quan Trọng
no-floating-promises: "error" — Đây là rule quan trọng nhất cho backend development. Floating promise là promise bạn quên await:
// BUG: Error bị nuốt im lặng
app.get("/users", (req, res) => {
fetchUsers(); // Quên await! Không error handling!
res.json({ ok: true }); // Gửi response trước khi data sẵn sàng
});
// FIXED: ESLint bắt được lỗi này
app.get("/users", async (req, res) => {
const users = await fetchUsers(); // Giờ chúng ta chờ
res.json(users);
});no-misused-promises: "error" — Bắt async functions truyền vào chỗ cần sync callbacks. Đây là nguồn phổ biến gây unhandled rejections.
Prettier Configuration (.prettierrc)
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2
}Thêm Scripts vào package.json
{
"scripts": {
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write 'src/**/*.ts'"
}
}4. Development Workflow
Bạn cần cách chạy TypeScript trong lúc dev mà không phải manually compile mỗi lần. Đây là ba lựa chọn, từ đơn giản nhất đến configurable nhất.
Option 1: tsx (Recommended)
tsx là TypeScript executor siêu nhanh chạy bằng esbuild. It just works — không config, không rắc rối:
npm install -D tsx{
"scripts": {
"dev": "tsx watch src/index.ts"
}
}Tại sao tsx?
- Zero config — không cần
tsconfig.jsonriêng để chạy (vẫn tôn trọng config của bạn) - Built-in watch mode với
tsx watch - Handle ESM và CJS mượt mà
- Nhanh hơn
ts-node~10 lần - Support path aliases out of the box
Option 2: ts-node (Legacy)
TypeScript executor đầu tiên. Vẫn dùng rộng rãi nhưng chậm hơn:
npm install -D ts-node{
"scripts": {
"dev": "ts-node --esm --watch src/index.ts"
}
}Khi nào dùng
ts-node: Nếu bạn cần full TypeScript type-checking khi chạy (tsx bỏ qua type-checking để nhanh hơn). Với hầu hết workflow, type-checking trong IDE +tsc --noEmittrong CI là đủ rồi.
Option 3: nodemon + tsx
Khi bạn muốn kiểm soát nhiều hơn những gì trigger restart:
npm install -D nodemon tsxTạo nodemon.json:
{
"watch": ["src"],
"ext": "ts,json",
"ignore": ["src/**/*.test.ts"],
"exec": "tsx src/index.ts"
}{
"scripts": {
"dev": "nodemon"
}
}File Khởi Đầu
Tạo src/index.ts để verify mọi thứ hoạt động:
// src/index.ts
console.log("Hello from TypeScript backend!");
console.log(`Node.js ${process.version}`);
console.log(`Running in ${process.env.NODE_ENV ?? "development"} mode`);npm run dev
# Output:
# Hello from TypeScript backend!
# Node.js v22.x.x
# Running in development mode5. Build Process
Development dùng tsx cho nhanh (không cần compilation step). Production cần JavaScript đã compiled. Đây là ba cách tiếp cận.
Option 1: tsc (Standard)
TypeScript compiler chính thống. Đơn giản, đáng tin, officially supported:
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}Ưu điểm: Full type-checking khi build, tạo declaration files, source maps Nhược điểm: Chậm hơn các lựa chọn khác (5-10 lần)
Option 2: esbuild (Nhanh)
Bundler siêu nhanh viết bằng Go:
npm install -D esbuild{
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --outdir=dist --format=esm --packages=external",
"start": "node dist/index.js"
}
}Các flags quan trọng:
--bundle: Bundle tất cả code thành ít files hơn--platform=node: Target Node.js (không phải browser)--packages=external: Giữnode_moduleslà external imports (không bundle dependencies)--format=esm: Output ESM format
Ưu điểm: Nhanh hơn tsc 10-100 lần, bundle code, tree-shake dead code
Nhược điểm: Không type-check (kết hợp với tsc --noEmit trong CI)
Option 3: swc (Nhanh + Configurable)
Compiler viết bằng Rust, được dùng bởi Next.js:
npm install -D @swc/cli @swc/coreTạo .swcrc:
{
"$schema": "https://swc.rs/schema.json",
"module": {
"type": "es6"
},
"jsc": {
"target": "es2022",
"parser": {
"syntax": "typescript",
"decorators": true
},
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}{
"scripts": {
"build": "swc src -d dist --strip-leading-paths",
"start": "node dist/index.js"
}
}Recommended: Hybrid Approach
Kết hợp cả hai thế giới — build nhanh với type safety:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --noEmit && esbuild src/index.ts --bundle --platform=node --outdir=dist --format=esm --packages=external",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
}
}Chạy type-checking (tsc --noEmit) trước, rồi build với esbuild nếu types pass. Bạn được cả safety lẫn speed.
6. Cấu Trúc Project
Cấu trúc project tổ chức tốt là critical cho maintainability. Đây là layout đã được thực chiến cho backend projects:
my-api/
├── src/
│ ├── index.ts # Entry point - khởi động server
│ ├── app.ts # Express/Fastify app setup
│ ├── config/
│ │ └── env.ts # Environment variable validation
│ ├── routes/
│ │ ├── index.ts # Route aggregator
│ │ ├── users.ts # /api/users routes
│ │ └── posts.ts # /api/posts routes
│ ├── controllers/
│ │ ├── user.controller.ts
│ │ └── post.controller.ts
│ ├── services/
│ │ ├── user.service.ts
│ │ └── post.service.ts
│ ├── repositories/
│ │ ├── user.repository.ts
│ │ └── post.repository.ts
│ ├── middleware/
│ │ ├── auth.ts # Authentication middleware
│ │ ├── error-handler.ts # Global error handler
│ │ └── validate.ts # Request validation
│ ├── types/
│ │ ├── index.ts # Shared types
│ │ └── express.d.ts # Express type extensions
│ └── utils/
│ ├── logger.ts # Logging utility
│ └── errors.ts # Custom error classes
├── tests/
│ ├── unit/
│ └── integration/
├── .env # Local environment variables
├── .env.example # Template cho environment variables
├── .gitignore
├── .prettierrc
├── eslint.config.mjs
├── nodemon.json
├── package.json
└── tsconfig.jsonKiến Trúc Phân Lớp
Cấu trúc này theo pattern Controller → Service → Repository:
Routes — Định nghĩa endpoints và kết nối với controllers. Không có business logic ở đây.
Controllers — Xử lý HTTP concerns (parse request, gửi response). Delegate cho services.
Services — Business logic sống ở đây. Framework-agnostic. Testable riêng biệt.
Repositories — Database access layer. SQL queries, ORM calls. Services không biết gì về SQL.
Ví dụ cách chúng kết nối:
// src/routes/users.ts
import { Router } from "express";
import { UserController } from "@/controllers/user.controller.js";
const router = Router();
const controller = new UserController();
router.get("/", controller.getAll);
router.get("/:id", controller.getById);
router.post("/", controller.create);
export default router;// src/controllers/user.controller.ts
import type { Request, Response, NextFunction } from "express";
import { UserService } from "@/services/user.service.js";
export class UserController {
private service = new UserService();
getAll = async (_req: Request, res: Response, next: NextFunction) => {
try {
const users = await this.service.findAll();
res.json(users);
} catch (error) {
next(error);
}
};
getById = async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await this.service.findById(req.params.id!);
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
res.json(user);
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await this.service.create(req.body);
res.status(201).json(user);
} catch (error) {
next(error);
}
};
}// src/services/user.service.ts
import { UserRepository } from "@/repositories/user.repository.js";
export class UserService {
private repository = new UserRepository();
async findAll() {
return this.repository.findMany();
}
async findById(id: string) {
return this.repository.findById(id);
}
async create(data: { name: string; email: string }) {
// Business logic: validate, check duplicates, v.v.
const existing = await this.repository.findByEmail(data.email);
if (existing) {
throw new Error("Email already exists");
}
return this.repository.create(data);
}
}Tại Sao Sự Phân Tách Này Quan Trọng
Bạn có thể nghĩ đây là overkill cho project nhỏ. Đây là lý do nó đáng giá:
- Testing: Services test được không cần HTTP, repositories test được không cần business logic
- Swapping: Thay Express bằng Fastify? Chỉ routes và controllers thay đổi
- Readability: Developer mới biết chính xác tìm gì ở đâu
- Scaling: Khi file quá lớn, ranh giới đã được vạch sẵn
7. Environment Variables
Không bao giờ hardcode secrets. Không bao giờ tin process.env mà không validate.
Vấn Đề với process.env Thô
// BAD: Không validation, không types, crash khi runtime
const port = process.env.PORT; // string | undefined
const dbUrl = process.env.DATABASE_URL; // có thể không tồn tại!
// App khởi động, connect tới... hư không. Crash sau 5 phút
// khi database query đầu tiên chạy.Environment Type-Safe với Zod
Cài Zod:
npm install zodTạo src/config/env.ts:
// src/config/env.ts
import { z } from "zod";
const envSchema = z.object({
// Server
PORT: z.coerce.number().default(3000),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
HOST: z.string().default("0.0.0.0"),
// Database
DATABASE_URL: z.string().url("DATABASE_URL phải là URL hợp lệ"),
// Auth
JWT_SECRET: z.string().min(32, "JWT_SECRET phải ít nhất 32 ký tự"),
JWT_EXPIRES_IN: z.string().default("7d"),
// External services (optional trong development)
REDIS_URL: z.string().url().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().optional(),
});
// Validate khi import — fail fast nếu env sai
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("❌ Invalid environment variables:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
// Type tự động được infer:
// {
// PORT: number;
// NODE_ENV: "development" | "production" | "test";
// HOST: string;
// DATABASE_URL: string;
// JWT_SECRET: string;
// JWT_EXPIRES_IN: string;
// REDIS_URL?: string;
// SMTP_HOST?: string;
// SMTP_PORT?: number;
// }Sử Dụng Trong Toàn App
// src/index.ts
import { env } from "@/config/env.js";
// Fully typed! IDE autocomplete hoạt động.
console.log(`Starting server on port ${env.PORT}`);
console.log(`Environment: ${env.NODE_ENV}`);
// env.PORT là number (không phải string!)
// env.NODE_ENV là "development" | "production" | "test" (không phải string)Tạo .env.example
# .env.example — Copy thành .env và điền giá trị
PORT=3000
NODE_ENV=development
HOST=0.0.0.0
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=your-secret-key-at-least-32-characters-long
JWT_EXPIRES_IN=7d
# Optional
REDIS_URL=redis://localhost:6379Đừng Quên .gitignore
# Environment
.env
.env.local
.env.*.local
# Build output
dist/
# Dependencies
node_modules/
# IDE
.vscode/
.idea/
# OS
.DS_Store8. Path Aliases
Path aliases thay thế import đường dẫn dài bằng đường dẫn gọn:
// KHÔNG CÓ aliases (đau đầu)
import { UserService } from "../../../services/user.service.js";
import { env } from "../../../../config/env.js";
// CÓ aliases (sạch sẽ)
import { UserService } from "@/services/user.service.js";
import { env } from "@/config/env.js";Bước 1: Cấu Hình TypeScript
Đã làm trong tsconfig.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}Bước 2: Làm Cho Nó Chạy Được Lúc Runtime
TypeScript hiểu path aliases cho type-checking, nhưng Node.js thì không. Bạn cần runtime resolver.
Cho development (tsx): Path aliases hoạt động out of the box. Không cần config thêm.
Cho production (compiled JS): Bạn có hai options.
Option A: tsc-alias (sau compile)
npm install -D tsc-alias{
"scripts": {
"build": "tsc && tsc-alias"
}
}tsc-alias rewrite import paths trong compiled .js files từ @/ thành relative paths.
Option B: esbuild tự xử lý
Nếu bạn dùng esbuild để build, nó resolve path aliases trong quá trình bundling. Không cần bước thêm — chỉ trỏ tới tsconfig.json:
{
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --outdir=dist --format=esm --packages=external --tsconfig=tsconfig.json"
}
}9. package.json Hoàn Chỉnh
Đây là package.json đầy đủ kết nối mọi thứ:
{
"name": "my-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --noEmit && esbuild src/index.ts --bundle --platform=node --outdir=dist --format=esm --packages=external --tsconfig=tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write 'src/**/*.ts'"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@swc/cli": "^0.5.0",
"@swc/core": "^1.9.0",
"@types/node": "^22.0.0",
"esbuild": "^0.24.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^10.0.0",
"prettier": "^3.4.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.0.0"
},
"dependencies": {
"zod": "^3.24.0"
}
}10. Kiểm Tra: Hello World API
Hãy verify mọi thứ hoạt động end-to-end với một Express server tối thiểu:
npm install express
npm install -D @types/express// src/index.ts
import express from "express";
import { env } from "@/config/env.js";
const app = express();
app.use(express.json());
app.get("/", (_req, res) => {
res.json({
message: "Hello from TypeScript!",
environment: env.NODE_ENV,
timestamp: new Date().toISOString(),
});
});
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
app.listen(env.PORT, env.HOST, () => {
console.log(`Server running at http://${env.HOST}:${env.PORT}`);
console.log(`Environment: ${env.NODE_ENV}`);
});Tạo file .env:
PORT=3000
NODE_ENV=development
HOST=0.0.0.0
DATABASE_URL=postgresql://localhost:5432/mydb
JWT_SECRET=my-development-secret-key-at-least-32-charsChạy thử:
npm run dev
# Output:
# Server running at http://0.0.0.0:3000
# Environment: developmentTest:
curl http://localhost:3000
# {"message":"Hello from TypeScript!","environment":"development","timestamp":"2026-03-01T..."}
curl http://localhost:3000/health
# {"status":"ok"}Build và chạy production:
npm run build
npm startTổng Kết
Đây là những gì chúng ta đã setup và tại sao:
| Thành phần | Tool | Lý do |
|---|---|---|
| TypeScript config | tsconfig.json | Strict types, ES2022, NodeNext modules |
| Linting | ESLint 9 + typescript-eslint | Bắt async bugs, enforce consistency |
| Formatting | Prettier | Code style nhất quán, không tranh cãi |
| Dev runner | tsx watch | Chạy nhanh, auto-reload, zero config |
| Build | tsc --noEmit + esbuild | Type safety + bundling nhanh |
| Env vars | Zod validation | Fail fast, typed config, không surprise |
| Path aliases | @/* → ./src/* | Import sạch, không ../../../ |
| Structure | Controller/Service/Repository | Separation of concerns, testable layers |
Tiếp Theo
Setup này là nền tảng cho mọi thứ tiếp theo. Trong bài tiếp, chúng ta sẽ build REST API hoàn chỉnh trên nền tảng này:
- Express.js routing với type-safe request/response
- Input validation với Zod
- Error handling middleware
- Controller/Service/Repository pattern thực chiến
- API documentation với Swagger
Series: TypeScript Full-Stack Development
Trước: TypeScript Full-Stack Monorepo
Tiếp: TypeScript: Xây Dựng REST API với Express.js
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.