Clean Code với TypeScript: Nguyên tắc thiết kế mã rõ ràng, an toàn và dễ bảo trì
Mở đầu
Clean Code không chỉ là việc đặt tên biến đẹp hoặc chia một hàm dài thành nhiều hàm nhỏ. Với TypeScript, mã sạch còn phải thể hiện đúng hợp đồng dữ liệu, tận dụng kiểm tra kiểu tĩnh, giới hạn trạng thái không hợp lệ và tạo điều kiện để compiler hỗ trợ quá trình refactor. Mục tiêu cuối cùng là làm cho ý định của chương trình trở nên rõ ràng đối với cả con người lẫn công cụ.
Một codebase tốt không nhất thiết có ít dòng code nhất. Nó là codebase mà một lập trình viên mới có thể lần theo luồng xử lý, hiểu dữ liệu đi qua những trạng thái nào, biết lỗi được xử lý ở đâu và tự tin thay đổi một phần mà không phá vỡ phần còn lại. TypeScript giúp đạt mục tiêu ấy, nhưng chỉ khi hệ thống kiểu được dùng để mô hình hóa miền nghiệp vụ thay vì chỉ thêm annotation cho có.
Nguyên tắc trung tâm: Hãy viết code để ý định được nhìn thấy, dữ liệu không hợp lệ bị chặn sớm và mỗi module chỉ chịu trách nhiệm cho một số lượng nhỏ quyết định.
Bài viết này trình bày một cách tiếp cận thực tế, từ cấu hình compiler đến thiết kế hàm, kiểu dữ liệu, class, dependency, bất đồng bộ, xử lý lỗi, kiểm thử và quy trình review.
1. Đặt nền móng bằng compiler và công cụ
Mã sạch bắt đầu từ một tiêu chuẩn có thể kiểm tra tự động. Không nên giao toàn bộ chất lượng code cho trí nhớ hoặc thói quen của từng thành viên trong nhóm.
TypeScript được thiết kế như một static typechecker cho chương trình JavaScript: nó phân tích code trước khi chạy và phát hiện nhiều lỗi do dùng sai kiểu. Tùy chọn strict bật một nhóm lớn các kiểm tra nhằm tạo ra bảo đảm mạnh hơn về tính đúng đắn của chương trình.
Một cấu hình khởi đầu có thể như sau:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"useUnknownInCatchVariables": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src", "tests"]
}Không phải mọi dự án đều dùng cùng một target, module hoặc bundler. Tuy nhiên, việc bật strict và chạy tsc --noEmit trong CI thường là nền tảng hợp lý. Cần nhớ rằng việc bật strict có thể làm xuất hiện lỗi mới khi nâng cấp TypeScript, vì các phiên bản tương lai có thể bổ sung kiểm tra nghiêm ngặt hơn dưới cờ này.
Bên cạnh compiler, linting có thể bổ sung các quy tắc mà typechecker không nhằm giải quyết, chẳng hạn phát hiện API nguy hiểm, promise bị bỏ quên hoặc cách dùng assertion đáng ngờ. typescript-eslint cung cấp các preset có thông tin kiểu; các preset này phân tích toàn bộ project nên mạnh hơn nhưng cũng chậm hơn linting thông thường.
| Lớp kiểm tra | Câu hỏi cần trả lời | Công cụ phù hợp |
|---|---|---|
| Kiểu tĩnh | Giá trị có đúng kiểu và hợp đồng không? | tsc |
| Quy ước | Code có tuân thủ quy tắc đặt tên, import và cấu trúc không? | ESLint, typescript-eslint |
| Hành vi | Chương trình có cho kết quả đúng không? | Unit test, integration test |
| Biên runtime | JSON, input người dùng và response API có hợp lệ không? | Schema validation, type guard |
| Tích hợp | Các module, database và dịch vụ có phối hợp đúng không? | Integration và end-to-end test |
Không có một lớp nào thay thế được các lớp còn lại. TypeScript không kiểm tra dữ liệu sau khi chương trình chạy, và test không thể thay thế một hợp đồng kiểu rõ ràng.
2. Tên gọi phải thể hiện ý định
Tên biến, hàm, kiểu và module là lớp tài liệu gần code nhất. Một tên tốt trả lời được câu hỏi “giá trị này đại diện cho điều gì?” mà không buộc người đọc phải mở thêm nhiều file.
// Không rõ: giá trị là mili-giây, giây hay số lần thử?
const timeout = 30;
// Rõ hơn: đơn vị và ý nghĩa được thể hiện ngay trong tên.
const REQUEST_TIMEOUT_MS = 30_000;
const MAX_RETRY_ATTEMPTS = 3;Tên boolean nên đọc như một mệnh đề đúng hoặc sai. Các tiền tố is, has, can và should thường làm ý nghĩa rõ hơn.
const isEmailVerified = user.emailVerified;
const hasAvailableStock = product.stock > 0;
const canPublishArticle = actor.permissions.includes("article:publish");Không nên dùng nhiều từ khác nhau cho cùng một khái niệm. Nếu domain có customer, đừng lúc thì gọi là client, lúc thì gọi là buyer trừ khi ba khái niệm đó thực sự khác nhau. Sự nhất quán giúp tìm kiếm code nhanh hơn và giảm nhầm lẫn trong API.
Tên kiểu cũng cần phản ánh vai trò của dữ liệu. User có thể là entity đầy đủ, UserId là định danh, CreateUserInput là dữ liệu đầu vào và UserResponse là hình dạng trả về API. Đừng dùng một kiểu duy nhất cho mọi boundary chỉ vì các thuộc tính hiện tại giống nhau.
type UserId = string & { readonly __brand: "UserId" };
interface User {
readonly id: UserId;
readonly email: string;
readonly displayName: string;
readonly isActive: boolean;
}
interface CreateUserInput {
readonly email: string;
readonly displayName: string;
}Branded type không tự xác thực chuỗi. Nó chỉ giúp ngăn việc vô tình truyền một string chưa được tạo đúng cách vào nơi cần UserId. Việc tạo UserId vẫn phải nằm sau bước kiểm tra hợp lệ.
3. Dùng type để mô hình hóa miền nghiệp vụ
Type annotation hữu ích nhất ở ranh giới giữa các phần của hệ thống: tham số hàm công khai, dữ liệu trả về, message, sự kiện, adapter và input từ bên ngoài. Ở phạm vi cục bộ, hãy để inference làm việc nếu kiểu đã hiển nhiên.
// Không cần lặp lại kiểu mà compiler đã suy ra rõ ràng.
const activeStatuses = ["pending", "approved"] as const;
// Cần khai báo vì đây là hợp đồng của module.
export function findUser(userId: UserId): Promise<User | undefined> {
return userRepository.findById(userId);
}3.1. Tránh any, ưu tiên unknown
any tắt phần lớn bảo vệ của hệ thống kiểu tại vị trí sử dụng. unknown an toàn hơn vì buộc code phải chứng minh giá trị có thể được dùng như kiểu cụ thể.
function parseJson(json: string): unknown {
return JSON.parse(json);
}
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "string" &&
typeof candidate.email === "string" &&
typeof candidate.displayName === "string" &&
typeof candidate.isActive === "boolean"
);
}
function readUser(json: string): User {
const value = parseJson(json);
if (!isUser(value)) {
throw new Error("Invalid user payload");
}
return value;
}Ở ví dụ trên, assertion cục bộ as Record<string, unknown> chỉ phục vụ việc đọc thuộc tính sau khi đã kiểm tra đây là object. Hàm isUser mới là nơi đưa ra quyết định runtime. Không nên dùng JSON.parse(json) as User vì assertion không biến đổi hoặc xác thực dữ liệu.
3.2. Dùng union có phân biệt cho trạng thái
Nhiều boolean rời rạc dễ tạo ra trạng thái mâu thuẫn, chẳng hạn isLoading: true đồng thời có error. Discriminated union biểu diễn chính xác những trạng thái hợp lệ.
type RequestState<T> =
| { readonly status: "idle" }
| { readonly status: "loading" }
| { readonly status: "success"; readonly data: T }
| { readonly status: "error"; readonly error: Error };
function renderUsers(state: RequestState<readonly User[]>): string {
switch (state.status) {
case "idle":
return "Chưa bắt đầu";
case "loading":
return "Đang tải...";
case "success":
return `${state.data.length} người dùng`;
case "error":
return `Lỗi: ${state.error.message}`;
}
}Nếu union có thêm biến thể trong tương lai, compiler có thể nhắc chúng ta cập nhật các nhánh xử lý. Với những switch phức tạp, có thể dùng hàm assertNever để kiểm tra exhaustiveness một cách tường minh.
Narrowing là một phần của thiết kế hàm
Khi một tham số có union type, đừng vội ép kiểu. Hãy dùng control flow để thu hẹp kiểu: typeof cho primitive, in cho thuộc tính, instanceof cho class, equality check cho literal và type predicate cho domain object.
function padLeft(padding: number | string, input: string): string {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
interface Guest {
readonly kind: "guest";
readonly name: string;
}
interface Member {
readonly kind: "member";
readonly name: string;
readonly memberSince: Date;
}
type Visitor = Guest | Member;
function isMember(visitor: Visitor): visitor is Member {
return visitor.kind === "member";
}
function describeVisitor(visitor: Visitor): string {
if (isMember(visitor)) {
return `${visitor.name} joined in ${visitor.memberSince.getFullYear()}`;
}
return `Guest: ${visitor.name}`;
}Type predicate không tự động làm dữ liệu runtime trở nên đúng; phần thân của predicate phải được viết và kiểm thử cẩn thận. Assertion function cũng chỉ nên được dùng khi hàm thực sự kiểm tra điều kiện và ném lỗi nếu điều kiện sai. Với strictNullChecks, hãy xử lý null và undefined như những trạng thái thật, không dùng ! để che giấu chúng.
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${String(value)}`);
}3.3. interface, type, generic và utility type
Không có một quy tắc tuyệt đối rằng interface luôn tốt hơn type hoặc ngược lại. Hãy chọn công cụ phản ánh ý định.
| Công cụ | Phù hợp khi |
|---|---|
interface | Mô tả shape của object hoặc contract mà class có thể triển khai. |
type | Mô tả union, tuple, primitive alias, mapped type hoặc conditional type. |
| Generic | Một thuật toán giữ nguyên quan hệ kiểu giữa input và output. |
Pick, Omit, Partial, Readonly, Record | Tạo biến thể có liên hệ từ một type đã có. |
interface Page<T> {
readonly items: readonly T[];
readonly total: number;
readonly page: number;
}
type PublicUser = Pick<User, "id" | "displayName">;
type UpdateUserInput = Partial<Pick<User, "email" | "displayName">>;
function first<T>(items: readonly T[]): T | undefined {
return items[0];
}Đừng dùng utility type để che giấu một mô hình dữ liệu khó hiểu. Nếu Partial<User> cho phép cập nhật cả những thuộc tính không được phép thay đổi, hãy tạo một input type cụ thể thay vì tái sử dụng mù quáng entity đầy đủ.
Dùng satisfies khi cần kiểm tra contract và giữ inference
Khi một object literal cần tuân thủ một contract nhưng vẫn cần giữ lại các key và literal type cụ thể, satisfies thường phù hợp hơn annotation rộng hoặc assertion. Nó kiểm tra tính tương thích tại compile time mà không làm giá trị bị mở rộng thành kiểu tổng quát hơn.
type ThemeValue = string | readonly [number, number, number];
type Theme = Record<string, ThemeValue>;
const palette = {
primary: "#2563eb",
accent: [16, 185, 129] as const,
} satisfies Theme;
const primaryColor = palette.primary.toUpperCase();satisfies không xác thực dữ liệu runtime và không thay thế schema parser. Nó chỉ giúp compiler kiểm tra contract của giá trị trong mã nguồn. Hãy dùng nó cho configuration, route map, event map và các object literal cần vừa chính xác vừa giữ inference.
4. Thiết kế hàm nhỏ, rõ và có hợp đồng
Một hàm nên thực hiện một nhiệm vụ có thể mô tả bằng một câu. “Một nhiệm vụ” không nhất thiết chỉ là một dòng code; nó có nghĩa là các bước bên trong cùng phục vụ một quyết định ở cùng mức trừu tượng.
Hàm nên có ít tham số. Khi có nhiều tham số liên quan, một input object có kiểu thường làm API dễ đọc hơn, nhưng không nên tạo object chỉ để né một thiết kế xấu.
interface CreateInvoiceInput {
readonly customerId: UserId;
readonly currency: "USD" | "EUR" | "VND";
readonly lineItems: readonly InvoiceLineInput[];
}
interface InvoiceLineInput {
readonly description: string;
readonly quantity: number;
readonly unitPrice: number;
}
function createInvoice(input: CreateInvoiceInput): Invoice {
validateInvoiceInput(input);
const total = input.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0,
);
return Invoice.create(input.customerId, input.currency, input.lineItems, total);
}Tên hàm nên nói rõ hành động và kết quả. getUser có thể chấp nhận được trong ngữ cảnh đơn giản, nhưng findUserById, loadCurrentAccount và calculateInvoiceTotal truyền tải ý định chính xác hơn.
Tránh trộn nhiều mức trừu tượng trong cùng một hàm. Một hàm điều phối use case không nên đồng thời chứa SQL, logic định dạng tiền và quy tắc quyền truy cập. Hãy tách các quyết định đó thành các abstraction có tên.
async function publishArticle(articleId: string): Promise<void> {
const article = await articleRepository.findById(articleId);
ensureArticleExists(article);
ensureCanPublish(article, currentActor);
const published = article.publish(new Date());
await articleRepository.save(published);
await eventBus.publish({ type: "article.published", articleId });
}Hàm trên vẫn có thể được tách tiếp nếu use case phát triển, nhưng mỗi dòng đã thể hiện một bước nghiệp vụ ở cùng mức: tải, kiểm tra, chuyển trạng thái, lưu và phát sự kiện.
Tránh side effect ẩn
Một hàm có side effect nên cho người đọc biết điều đó qua tên hoặc contract. Hàm calculateTotal không nên âm thầm sửa giỏ hàng; saveUser có thể ghi database và cần được xử lý như một thao tác I/O.
// Hàm thuần: cùng input luôn cho cùng output và không thay đổi input.
function calculateTotal(
items: readonly CartItem[],
discountRate: number,
): number {
const subtotal = items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0,
);
return subtotal * (1 - discountRate);
}
// Side effect được thể hiện rõ bằng tên và kiểu Promise.
async function persistCart(cart: Cart): Promise<void> {
await cartRepository.save(cart);
}readonly không làm dữ liệu sâu bất biến trong mọi trường hợp, nhưng nó truyền đạt ý định và ngăn nhiều thao tác mutation trực tiếp ở compile time. Khi cần bất biến sâu, hãy thiết kế dữ liệu hoặc sử dụng công cụ phù hợp thay vì chỉ dựa vào một modifier.
4.1. Function type, callback và overload
TypeScript không chỉ mô tả kiểu của dữ liệu; nó còn mô tả chính cách một hàm được gọi. Khi truyền hàm như một dependency hoặc callback, hãy khai báo function type để contract được nhìn thấy ngay tại nơi sử dụng.
type Formatter<T> = (value: T, locale: string) => string;
function formatAll<T>(
values: readonly T[],
formatter: Formatter<T>,
locale: string,
): readonly string[] {
return values.map((value) => formatter(value, locale));
}Generic chỉ nên xuất hiện khi nó duy trì quan hệ giữa các giá trị. Một type parameter chỉ xuất hiện một lần trong signature thường không tạo thêm thông tin và có thể được thay bằng một kiểu cụ thể hơn. Hãy ưu tiên đẩy type parameter xuống nơi thực sự cần inference, dùng ít parameter nhất có thể và tránh generic lồng quá sâu.
function first<T>(items: readonly T[]): T | undefined {
return items[0];
}
const firstNumber = first([1, 2, 3]);
const firstName = first(["An", "Bình"]);Overload phù hợp khi một API có nhiều cách gọi thực sự khác nhau và mỗi cách gọi có output liên hệ rõ với input. Nếu chỉ khác phần implementation nhưng contract có thể biểu diễn bằng union, union thường đơn giản hơn overload.
function parseId(value: string): UserId;
function parseId(value: number): UserId;
function parseId(value: string | number): UserId {
const normalized = String(value).trim();
if (!normalized) throw new Error("ID must not be empty");
return normalized as UserId;
}Tham số optional phải phản ánh đúng khả năng vắng mặt. Callback optional không nên bị gọi mà không kiểm tra; nếu callback luôn cần thiết, hãy để nó là tham số bắt buộc. Rest parameter và destructuring cũng cần có kiểu rõ ở public API để người gọi không phải đoán cấu trúc dữ liệu.
5. Dữ liệu và object: ưu tiên tính bất biến
Object nên có invariant rõ ràng. Nếu một object chỉ hợp lệ khi startDate nhỏ hơn endDate, đừng để mọi caller tự nhớ quy tắc đó. Đặt validation ở constructor, factory hoặc boundary thích hợp.
class DateRange {
private constructor(
public readonly start: Date,
public readonly end: Date,
) {}
static create(start: Date, end: Date): DateRange {
if (start > end) {
throw new Error("Start date must not be after end date");
}
return new DateRange(start, end);
}
contains(date: Date): boolean {
return date >= this.start && date <= this.end;
}
}Getter và setter không phải lúc nào cũng tốt hơn property. Nếu setter cho phép object đi vào trạng thái không hợp lệ, hãy thay bằng method biểu đạt hành động và kiểm soát invariant.
class BankAccount {
private balanceInCents = 0;
get balance(): number {
return this.balanceInCents;
}
deposit(amountInCents: number): void {
if (amountInCents <= 0) throw new Error("Deposit must be positive");
this.balanceInCents += amountInCents;
}
withdraw(amountInCents: number): void {
if (amountInCents <= 0 || amountInCents > this.balanceInCents) {
throw new Error("Invalid withdrawal");
}
this.balanceInCents -= amountInCents;
}
}Ở ranh giới API, không nên đưa trực tiếp object nội bộ ra ngoài nếu caller có thể sửa dữ liệu và phá invariant. Hãy trả về DTO bất biến hoặc bản sao có kiểm soát.
6. Class, composition và abstraction vừa đủ
Class phù hợp khi object có identity, invariant và hành vi gắn với dữ liệu. Không nên tạo class chỉ để bọc một nhóm hàm thuần hoặc biến một cấu trúc dữ liệu đơn giản thành hệ thống kế thừa nhiều tầng.
Composition thường linh hoạt hơn inheritance. Thay vì tạo PremiumOrder extends SpecialOrder extends BaseOrder, hãy ghép các chính sách có contract nhỏ.
interface PricingPolicy {
calculateTotal(order: Order): number;
}
interface ShippingPolicy {
calculateShipping(order: Order): number;
}
class CheckoutService {
constructor(
private readonly pricing: PricingPolicy,
private readonly shipping: ShippingPolicy,
) {}
calculateAmount(order: Order): number {
return this.pricing.calculateTotal(order) +
this.shipping.calculateShipping(order);
}
}Abstraction nên xuất hiện khi có lý do cụ thể: nhiều implementation, boundary cần mock, quy tắc thay đổi độc lập hoặc dependency cần đảo chiều. Một interface chỉ có một implementation và không bảo vệ quyết định nào có thể là abstraction sớm.
Không nên dùng public, private hoặc protected để tạo cảm giác an toàn mà không suy nghĩ về API. Field private của TypeScript chủ yếu là ràng buộc compile time; dữ liệu nhạy cảm vẫn cần được bảo vệ bằng thiết kế runtime, module boundary hoặc cơ chế bảo mật phù hợp.
7. SOLID trong TypeScript
SOLID không phải là yêu cầu tạo thật nhiều interface. Đây là nhóm nguyên tắc giúp giới hạn lý do thay đổi và giảm coupling.
Single Responsibility Principle
Một module nên có một nhóm lý do thay đổi liên quan với nhau. Repository không nên chứa cả logic tính giá, gửi email và chuyển đổi HTTP response.
class InvoiceCalculator {
calculate(invoice: Invoice): number {
return invoice.lines.reduce(
(total, line) => total + line.quantity * line.unitPrice,
0,
);
}
}
class InvoiceNotifier {
constructor(private readonly mailer: Mailer) {}
async notifyIssued(invoice: Invoice): Promise<void> {
await this.mailer.send(invoice.customerEmail, "Invoice issued");
}
}Open/Closed Principle
Code nên mở rộng được bằng cách thêm implementation hoặc policy mà không phải sửa một if khổng lồ. Union và exhaustive switch cũng là một lựa chọn tốt khi tập biến thể hữu hạn và việc thêm biến thể cần được compiler cảnh báo.
Liskov Substitution Principle
Một implementation của interface phải tuân thủ hành vi mà interface hứa hẹn, không chỉ khớp chữ ký. Nếu ReadableStore có method read, implementation không nên âm thầm ném lỗi cho một loại key hợp lệ chỉ vì nó không thích dữ liệu đó.
Interface Segregation Principle
Client không nên phụ thuộc vào contract lớn hơn nhu cầu của nó. Interface nhỏ thường dễ test và dễ thay thế hơn.
interface UserReader {
findById(id: UserId): Promise<User | undefined>;
}
interface UserWriter {
save(user: User): Promise<void>;
}
class UserProfileService {
constructor(private readonly users: UserReader) {}
getProfile(id: UserId): Promise<User | undefined> {
return this.users.findById(id);
}
}Dependency Inversion Principle
Logic nghiệp vụ cấp cao nên phụ thuộc vào abstraction, còn chi tiết như database, HTTP client hoặc filesystem nên triển khai abstraction đó.
interface PaymentGateway {
charge(input: ChargeInput): Promise<ChargeResult>;
}
class CheckoutUseCase {
constructor(private readonly payments: PaymentGateway) {}
async execute(input: CheckoutInput): Promise<Receipt> {
const result = await this.payments.charge({
customerId: input.customerId,
amountInCents: input.amountInCents,
});
if (!result.approved) throw new Error("Payment was declined");
return Receipt.create(input.orderId, result.transactionId);
}
}TypeScript structural typing giúp test double nhỏ dễ thỏa mãn interface, nhưng vẫn cần kiểm tra rằng double mô phỏng đúng hành vi quan trọng, không chỉ đúng shape.
8. Xử lý bất đồng bộ và concurrency
Promise nên được biểu diễn trong kiểu trả về. Điều này khiến caller biết nó phải await hoặc xử lý rejection.
async function loadAccount(id: UserId): Promise<User> {
const account = await accountRepository.findById(id);
if (!account) throw new AccountNotFoundError(id);
return account;
}Dùng Promise.all khi các tác vụ độc lập và có thể chạy song song; dùng tuần tự khi tác vụ sau phụ thuộc kết quả tác vụ trước hoặc khi giới hạn concurrency là một yêu cầu nghiệp vụ.
const [profile, preferences] = await Promise.all([
profileService.load(userId),
preferenceService.load(userId),
]);Đừng dùng forEach với callback async nếu bạn cần chờ hoàn tất. Chọn rõ giữa tuần tự và song song.
// Tuần tự: phù hợp khi cần giới hạn tải hoặc giữ thứ tự.
for (const job of jobs) {
await processJob(job);
}
// Song song: phù hợp khi các job độc lập và số lượng được kiểm soát.
await Promise.all(jobs.map((job) => processJob(job)));Một Promise bị bỏ quên là lỗi thiết kế, không chỉ là lỗi style. Linting dựa trên type information có thể hỗ trợ phát hiện trường hợp này. Với các thao tác độc lập, Promise.all thường giảm thời gian chờ; nhưng không nên biến mọi vòng lặp tuần tự thành song song nếu có rate limit, thứ tự nghiệp vụ hoặc giới hạn tài nguyên.
Nếu một hàm async chỉ trả về nguyên một Promise mà không thêm xử lý, có thể bỏ wrapper async để giảm một lớp Promise không cần thiết:
function loadUser(id: UserId): Promise<User> {
return userRepository.findByIdOrThrow(id);
}Chỉ dùng async khi cần await, try/catch tại boundary hoặc cần bảo đảm API luôn trả Promise.
9. Error handling có chủ đích
Không nên bắt lỗi chỉ để log rồi tiếp tục như thể không có chuyện gì xảy ra. Mỗi catch cần một mục đích: thêm ngữ cảnh, chuyển đổi lỗi, khôi phục, retry hoặc trả response thích hợp.
class AccountNotFoundError extends Error {
constructor(public readonly accountId: UserId) {
super(`Account not found: ${accountId}`);
this.name = "AccountNotFoundError";
}
}
async function getAccountOrThrow(id: UserId): Promise<Account> {
try {
const account = await accountRepository.findById(id);
if (!account) throw new AccountNotFoundError(id);
return account;
} catch (error: unknown) {
if (error instanceof AccountNotFoundError) throw error;
throw new Error(`Unable to load account ${id}`, { cause: error });
}
}Từ các phiên bản TypeScript phù hợp, biến trong catch có thể được xem là unknown, phản ánh đúng thực tế rằng JavaScript có thể throw bất kỳ giá trị nào. Hãy kiểm tra trước khi đọc thuộc tính.
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return `Unexpected failure: ${String(error)}`;
}Không nên dùng Error cho mọi kết quả nghiệp vụ nếu caller cần phân biệt các trường hợp đã biết. Với nhiều trạng thái bình thường, một Result<T, E> có thể rõ ràng hơn exception.
type Result<T, E> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
type LoginError = "invalid-credentials" | "locked-account";
function login(username: string, password: string): Result<Session, LoginError> {
// ...
return { ok: false, error: "invalid-credentials" };
}10. Kiểm thử theo hành vi
Test sạch kiểm tra một ý tưởng hoặc một hành vi chính. Tên test nên mô tả điều kiện và kết quả mong đợi, không mô tả chi tiết implementation dễ thay đổi.
describe("DateRange", () => {
it("accepts a date inside the range", () => {
const range = DateRange.create(
new Date("2026-01-01"),
new Date("2026-01-31"),
);
expect(range.contains(new Date("2026-01-15"))).toBe(true);
});
it("rejects a range whose start is after its end", () => {
expect(() =>
DateRange.create(
new Date("2026-02-01"),
new Date("2026-01-01"),
),
).toThrow();
});
});TypeScript làm test dễ refactor hơn khi fixture có kiểu rõ, nhưng không đảm bảo test có ý nghĩa. Tránh ép kiểu trong dữ liệu test để làm im lỗi compiler; nếu fixture khó tạo, đó có thể là dấu hiệu API đang quá phức tạp.
Các lớp kiểm thử nên bổ trợ nhau:
| Loại test | Phạm vi | Mục tiêu |
|---|---|---|
| Unit | Một hàm hoặc một domain object | Kiểm tra quy tắc cục bộ nhanh và chính xác. |
| Integration | Nhiều module hoặc adapter thật | Kiểm tra contract giữa các thành phần. |
| End-to-end | Luồng người dùng hoặc API | Xác nhận hệ thống hoạt động như một sản phẩm. |
| Type test | Kiểm tra cách API được sử dụng | Đảm bảo generic, overload và public type không bị phá vỡ. |
Đừng chạy coverage như một mục tiêu độc lập. Coverage cao nhưng chỉ kiểm tra happy path vẫn có thể bỏ sót lỗi quan trọng.
11. Formatting, module và cấu trúc file
Formatting nên được tự động hóa để review tập trung vào logic. Một codebase nên thống nhất về dấu phẩy cuối, quote, độ dài dòng, thứ tự import và quy tắc đặt tên. Prettier có thể xử lý phần lớn formatting; ESLint xử lý các quy tắc mang tính ngữ nghĩa hơn.
Module nên có public API nhỏ. Nếu một file chứa hàng chục export không liên quan, người đọc khó biết đâu là abstraction chính. Tổ chức code theo capability hoặc feature thường giúp dependency gần nhau hơn so với việc gom mọi types, utils và services vào các thư mục toàn cục quá lớn.
Các hàm caller và callee thường nên ở gần nhau về mặt đọc code, trừ khi module boundary rõ ràng khiến việc tách file có lợi. Import cycle là dấu hiệu cần xem lại hướng phụ thuộc.
Không lạm dụng file index.ts để re-export mọi thứ. Barrel file tiện cho import nhưng có thể làm dependency graph khó nhìn và khiến module được tải hoặc phụ thuộc ngoài dự kiến.
Khi chỉ import type, hãy dùng type-only import để thể hiện rõ dependency đó không cần tồn tại ở runtime.
import { createUser } from "./user-service.js";
import type { UserId } from "./user-types.js";Với codebase lớn, tránh import cycle bằng cách tách contract dùng chung thành module độc lập và giữ hướng phụ thuộc từ domain đến adapter. Project references có thể chia chương trình thành các phần nhỏ hơn, hỗ trợ build mode và giảm lượng code phải typecheck lại khi chỉ một project thay đổi.
{
"files": [],
"references": [
{ "path": "./packages/domain" },
{ "path": "./packages/application" }
]
}Project được reference thường cần cấu hình composite và declaration output. Không nên thêm references chỉ vì codebase có nhiều thư mục; hãy dùng khi ranh giới module và lợi ích build đủ rõ.
12. Hiệu năng của type system và quy trình build
Type system cũng có chi phí. Generic lồng quá sâu, conditional type đệ quy không giới hạn, union rất lớn và mapped type phức tạp có thể làm chậm compiler, IDE và linting có type information. Hãy ưu tiên type dễ đọc, tách type trung gian có tên và giới hạn độ sâu đệ quy khi domain cho phép.
Với hàm được export, khai báo return type rõ ràng giúp API ổn định hơn và làm lỗi xuất hiện ở đúng boundary. Tuy nhiên, không cần annotation mọi biến cục bộ nếu inference đã rõ. Khi một chuỗi intersection dài trở nên khó đọc hoặc khó chẩn đoán lỗi, hãy cân nhắc một interface có tên hoặc chia contract thành các type nhỏ hơn.
Đối với project lớn, bật incremental hoặc dùng project references sau khi đo build time thực tế. skipLibCheck có thể giảm thời gian kiểm tra declaration của dependency, nhưng không nên dùng nó để che lỗi trong code của project. include và exclude cần giới hạn đúng phạm vi để compiler không quét generated files, thư mục build hoặc fixture không cần thiết.
{
"compilerOptions": {
"strict": true,
"incremental": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
},
"include": ["src", "tests"],
"exclude": ["dist", "coverage", "node_modules"]
}Một cấu hình tối ưu không phải là cấu hình có nhiều cờ nhất. Hãy đo thời gian tsc, thời gian lint và trải nghiệm IDE trước và sau thay đổi; mọi cờ được bật hoặc tắt đều cần có lý do kỹ thuật.
13. Comment và tài liệu
Comment tốt giải thích tại sao, không lặp lại code đang làm gì.
// Tạm thời dùng cửa sổ 24 giờ vì hệ thống thanh toán gửi settlement theo ngày UTC.
// Không thay bằng local time nếu chưa cập nhật contract của payment provider.
const SETTLEMENT_WINDOW_MS = 24 * 60 * 60 * 1_000;Comment cần được cập nhật cùng code. Comment sai nguy hiểm hơn không có comment vì nó làm người đọc tin vào một điều không còn đúng. Không để code bị comment lại trong repository; hãy dùng version control để khôi phục lịch sử. Cũng không nên để các comment dạng nhật ký như “sửa lần hai”, “temporary fix” mà không nói rõ điều kiện loại bỏ.
Nếu một đoạn code cần comment dài để giải thích, trước hết hãy xem có thể đặt tên cho đoạn đó bằng một hàm hoặc type hay không. Tên tốt thường là documentation có thể được compiler kiểm tra.
14. Những cạm bẫy TypeScript thường gặp
Lạm dụng as
as không chuyển đổi dữ liệu. value as User chỉ bảo compiler tin vào người viết. Nếu assertion xuất hiện nhiều lần ở cùng một boundary, hãy tạo một parser hoặc schema validator duy nhất.
Dùng non-null assertion
element! và value! che giấu khả năng undefined. Hãy kiểm tra điều kiện, trả về union phù hợp hoặc thiết kế API để giá trị chắc chắn tồn tại.
Annotation quá mức
// Lặp lại inference mà không thêm thông tin.
const count: number = 0;
const names: string[] = ["An", "Bình"];
// Annotation có giá trị vì đây là public contract.
export function countActiveUsers(users: readonly User[]): number {
return users.filter((user) => user.isActive).length;
}Dùng enum khi literal union phù hợp hơn
Không phải mọi tập giá trị hữu hạn đều cần enum. Literal union thường đơn giản, dễ serialize và làm việc tốt với discriminated union.
type LogLevel = "debug" | "info" | "warn" | "error";Tin rằng type system thay thế validation
TypeScript không biết response HTTP thực tế có đúng schema hay không, không biết người dùng có quyền hay không và không ngăn được dữ liệu bị sửa bởi hệ thống khác. Runtime boundary vẫn phải được xác thực.
Với payload phức tạp, schema validator như Zod có thể trở thành nguồn định nghĩa cho cả validation runtime và kiểu TypeScript. Cách này giảm việc duy trì hai shape độc lập, nhưng schema vẫn cần được đặt ở boundary thay vì rải khắp domain logic.
import { z } from "zod";
const CreateUserSchema = z.object({
email: z.string().email(),
displayName: z.string().min(1).max(100),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
function parseCreateUserInput(value: unknown): CreateUserInput {
return CreateUserSchema.parse(value);
}Nếu không dùng thư viện schema, hãy viết type guard có test riêng. Dù chọn cách nào, nguyên tắc vẫn là: dữ liệu không tin cậy được kiểm tra một lần ở biên, sau đó đi vào phần còn lại của hệ thống dưới một kiểu đáng tin cậy.
Hiểu đúng optional property và undefined
property?: T mô tả một thuộc tính có thể không tồn tại. Điều này khác với một thuộc tính luôn tồn tại nhưng có giá trị undefined, được mô tả bằng property: T | undefined. Khi bật exactOptionalPropertyTypes, sự khác biệt này trở nên rõ hơn và giúp API biểu đạt chính xác hơn giữa “không truyền thuộc tính” và “truyền thuộc tính với giá trị undefined”.
interface CreateProfileInput {
readonly displayName: string;
readonly avatarUrl?: string;
}
interface LoadedProfile {
readonly displayName: string;
readonly avatarUrl: string | undefined;
}Hãy chọn mô hình dựa trên semantics của domain. Nếu undefined có ý nghĩa riêng, đừng dùng optional property để làm ngắn type một cách tùy tiện.
15. Quy trình review thực tế
Một pull request chất lượng không chỉ trả lời “code có chạy không?” mà còn trả lời “hợp đồng có rõ không?”, “trạng thái sai có bị loại bỏ không?” và “thay đổi sau này có dễ không?”.
| Câu hỏi review | Dấu hiệu tốt |
|---|---|
| Tên có thể hiện ý định không? | Không cần đọc implementation mới hiểu vai trò. |
| Public API có kiểu rõ không? | Input, output và lỗi được mô hình hóa. |
| Boundary có validate không? | unknown được narrow trước khi dùng. |
| Có trạng thái bất hợp lệ không? | Union phân biệt thay cho nhiều cờ rời rạc. |
| Dependency có hướng đúng không? | Domain không phụ thuộc trực tiếp vào framework hoặc database. |
| Test kiểm tra hành vi gì? | Test thất bại khi hành vi sai, không chỉ khi code bị đổi. |
| Compiler và lint có chạy trong CI không? | Chất lượng không phụ thuộc vào máy cá nhân. |
Review không nên biến thành cuộc tranh luận về sở thích nhỏ nếu formatter và linter đã giải quyết được chúng. Thời gian của reviewer nên dành cho boundary, invariant, error handling, concurrency và tính dễ thay đổi của thiết kế.
Kết luận
Clean Code trong TypeScript là sự kết hợp giữa thiết kế dễ hiểu và hợp đồng kiểu đáng tin cậy. Tên gọi phải thể hiện ý định; hàm phải có trách nhiệm rõ; object phải bảo vệ invariant; union phải mô hình hóa trạng thái; dependency phải đi qua abstraction vừa đủ; lỗi và bất đồng bộ phải được xử lý công khai; test phải kiểm tra hành vi; compiler, lint và CI phải trở thành một phần của quy trình phát triển.
Hãy dùng strict, nhưng đừng xem compiler là người thiết kế thay bạn. Hãy dùng unknown, nhưng đừng bỏ qua validation runtime. Hãy dùng interface và generic, nhưng đừng tạo abstraction chỉ để code trông “kiến trúc” hơn. Một lựa chọn TypeScript tốt là lựa chọn làm giảm sự mơ hồ và làm cho thay đổi trong tương lai trở nên an toàn hơn.
Mục tiêu cuối cùng: Người đọc có thể hiểu code mà không phải đoán; compiler có thể bắt lỗi trước runtime; và nhóm phát triển có thể thay đổi hệ thống với sự tự tin có cơ sở.
Tài liệu tham khảo
- The TypeScript Handbook — Introduction
- TypeScript TSConfig — strict
- typescript-eslint — Linting with Type Information
- TypeScript 4.9 — The satisfies Operator
- TypeScript — Project References
- TypeScript Handbook — Narrowing
- TypeScript Handbook — More on Functions
- TypeScript Handbook — Modules
- TypeScript TSConfig Reference