Đang tải...

Ếch Trendy

Cùng chú ếch nhỏ khám phá thế giới Web đầy biến động. Cập nhật trending nhanh như cách ếch đớp mồi! ⌨️🌿


0

Sử Dụng TypeScript Trong Dự Án Web Lớn: Hướng Dẫn Thực Chiến

Ếch Trendy
Ếch Trendy

5 tháng trước · 9 phút đọc

Tại sao dự án lớn không thể thiếu TypeScript

Mình từng join một dự án React đã chạy 2 năm. Codebase hơn 80,000 dòng JavaScript thuần. Mỗi lần sửa một function, mình phải đọc ít nhất 5-7 file khác để hiểu data đang có hình dạng gì. Không có IDE nào gợi ý được. Không có gì báo lỗi trước khi chạy.

Đó là lý do TypeScript tồn tại.

344384 Không có type = mò mẫm trong bóng tối khi codebase lớn dần

TypeScript không phải "JavaScript khó hơn". Nó là lớp bảo vệ giúp bạn phát hiện lỗi tại lúc viết code, thay vì lúc người dùng gặp bug trên production. Với dự án nhỏ, JS thuần vẫn ổn. Nhưng khi team có 5+ người, codebase vượt 10,000 dòng, TypeScript bắt đầu trả lời rất nhiều câu hỏi mà JS không thể.

Nhiều nghiên cứu cho thấy phần lớn dự án JavaScript lớn gặp lỗi runtime có thể phát hiện được ở compile time nếu dùng TypeScript - con số này lý giải tại sao hầu hết các tech stack hiện đại đều mặc định dùng TS.

Vấn đề thực sự không phải là "học TypeScript". Mà là học cách dùng TypeScript đúng cách trong môi trường team.


Cấu hình tsconfig đúng cho dự án lớn

tsconfig.json là nơi nhiều người setup cho có rồi thôi. Với dự án lớn, cấu hình sai ở đây sẽ khiến TypeScript không bảo vệ được gì đáng kể.

Mình recommend cấu hình tối thiểu sau:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,
    "paths": {
      "@components/*": ["src/components/*"],
      "@utils/*": ["src/utils/*"]
    }
  }
}

344387 Bật strict: true là bước đầu tiên - các options còn lại ngăn nhiều class bug phổ biến

strict: true không phải một option - nó bật 8 options cùng lúc, trong đó quan trọng nhất là strictNullChecks. Không có cái này, TypeScript gần như vô dụng.

noUncheckedIndexedAccess là option ít người biết nhưng cực kỳ hữu ích. Khi bạn dùng arr[0], TypeScript sẽ báo type là T | undefined thay vì T. Nghe phiền nhưng nó ngăn rất nhiều lỗi "Cannot read property of undefined".

paths giúp bạn dùng absolute imports thay vì ../../components/Button. Codebase 50 file thì không cần. Codebase 500 file thì bắt buộc.

Nếu bạn đang migrate một dự án JS cũ sang TS, đừng bật strict: true ngay. Bắt đầu với strict: false, từng bước bật từng option một. Một số team mình biết mất 3-6 tháng để migrate đúng cách - và đó là đầu tư xứng đáng.

Tải file tsconfig mẫu được tối ưu cho dự án lớn tại đây: Tải tsconfig mẫu cho dự án lớn


Type-safe API calls: tránh lỗi "surprise" từ backend

Một trong những nguồn bug phổ biến nhất trong frontend là dữ liệu từ API trả về khác với những gì frontend expect. Backend đổi tên field, thêm/bớt property - và frontend chỉ biết khi người dùng thấy màn hình trắng.

Cách fix? Define type cho mọi API response.

// Định nghĩa type cho API response
interface UserProfile {
  id: string;
  email: string;
  displayName: string;
  role: 'admin' | 'editor' | 'viewer'; // Union type, không phải string chung chung
  createdAt: string; // ISO date string
  avatarUrl?: string; // Optional - có thể undefined
}

// Generic fetch wrapper - dùng cho mọi API call
async function fetchApi<T>(endpoint: string): Promise<T> {
  const res = await fetch(`/api/${endpoint}`);
  if (!res.ok) throw new Error(`API error: ${res.status}`);
  return res.json() as Promise<T>;
}

// Sử dụng
const user = await fetchApi<UserProfile>('users/me');
// IDE tự gợi ý: user.displayName, user.role, v.v.

344390 Generic wrapper + interface = IDE gợi ý đúng, lỗi type xuất hiện trước khi chạy

Nhưng đây là điểm mà nhiều người dừng lại và bỏ qua phần quan trọng hơn: runtime validation. TypeScript chỉ kiểm tra lúc compile. Khi code chạy trên browser, dữ liệu từ API vẫn là JavaScript thuần. Nếu backend trả về data sai shape, TypeScript không bắt được.

Giải pháp là dùng thư viện như Zod để validate schema tại runtime:

import { z } from 'zod';

// Định nghĩa schema một lần - dùng cho cả type lẫn runtime validation
const UserProfileSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  displayName: z.string().min(1),
  role: z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.string().datetime(),
  avatarUrl: z.string().url().optional(),
});

// TypeScript tự suy ra type từ schema - không cần viết lại interface
type UserProfile = z.infer<typeof UserProfileSchema>;

async function fetchUserProfile(id: string): Promise<UserProfile> {
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();
  return UserProfileSchema.parse(data); // Throw lỗi rõ ràng nếu data sai
}

Zod đặc biệt hữu ích khi làm với dự án có nhiều team, nơi contract giữa frontend và backend hay thay đổi mà không có thông báo kịp thời.


Tổ chức types trong codebase lớn

Khi dự án lớn dần, câu hỏi không còn là "viết type thế nào" mà là "đặt type ở đâu".

Mình thấy 2 pattern phổ biến - và cả 2 đều có trade-off:

Pattern 1: Centralized types - tất cả types đặt trong thư mục src/types/

src/
  types/
    user.ts      # UserProfile, UserSettings, v.v.
    product.ts   # Product, ProductVariant
    api.ts       # ApiResponse<T>, PaginatedResponse<T>
    index.ts     # Re-export tất cả

Lợi ích: dễ tìm, tránh duplicate. Nhược điểm: file types trở thành "trash can" khi dự án lớn, khó biết type nào còn dùng hay không.

Pattern 2: Co-located types - types đặt cạnh code dùng nó

src/
  features/
    user/
      UserProfile.tsx
      UserProfile.types.ts  # Types riêng cho feature này
      userApi.ts

344395 Co-location giúp xóa types không còn dùng dễ hơn - xóa feature thì xóa luôn types

Mình prefer pattern 2 cho dự án theo kiến trúc feature-based. Nhưng với shared types dùng ở nhiều nơi (như ApiResponse<T>), vẫn cần một src/types/shared.ts riêng.

Một nguyên tắc khác: tránh dùng any. Mỗi lần bạn viết any, TypeScript mất khả năng bảo vệ tại điểm đó và tất cả những nơi nhận giá trị từ đó. Nếu cần escape hatch, dùng unknown rồi narrow type thay vì any.

// ❌ Tệ - lan rộng any không kiểm soát được
function parseData(raw: any) {
  return raw.user.name; // Không ai biết đây có tồn tại không
}

// ✅ Tốt - dùng unknown, buộc phải kiểm tra trước
function parseData(raw: unknown) {
  if (
    typeof raw === 'object' &&
    raw !== null &&
    'user' in raw &&
    typeof (raw as any).user?.name === 'string'
  ) {
    return (raw as { user: { name: string } }).user.name;
  }
  throw new Error('Dữ liệu không hợp lệ');
}

Lint rule @typescript-eslint/no-explicit-any sẽ giúp enforce điều này tự động trong CI/CD.


Utility types: viết ít, bảo trì nhiều

Đây là phần TypeScript mà nhiều người học được nhưng ít người dùng đủ. Utility types giúp bạn tái sử dụng và biến đổi types thay vì viết lại từ đầu.

Các utility types mình dùng nhiều nhất trong dự án thực tế:

Partial<T> và Required<T> - thay đổi optional/required

interface UserProfile {
  id: string;
  name: string;
  bio: string;
  avatarUrl: string;
}

// Form cập nhật: tất cả fields đều optional
type UpdateUserPayload = Partial<UserProfile>;

// DTO gửi lên server: bắt buộc hết
type CreateUserPayload = Required<Omit<UserProfile, 'id'>>;

Pick<T, K> và Omit<T, K> - chọn hoặc bỏ fields

// Chỉ lấy những gì cần hiển thị trên UI
type UserCardProps = Pick<UserProfile, 'name' | 'avatarUrl'>;

// Bỏ field không gửi lên server
type CreateUserPayload = Omit<UserProfile, 'id' | 'createdAt'>;

344398 Utility types giống LEGO - ghép lại từ các mảnh có sẵn thay vì đúc mới từ đầu

ReturnType<T> - lấy type từ return value của function

// Không cần export type riêng - lấy thẳng từ function
function getUserWithPermissions(id: string) {
  return { user: { id, name: '' }, permissions: ['read'] as const };
}

type UserWithPermissions = ReturnType<typeof getUserWithPermissions>;
// Type tự cập nhật khi function thay đổi

Pattern cuối này đặc biệt hữu ích khi làm với React Query hay SWR - type của data tự suy ra từ fetcher function, không cần khai báo thêm.

Nếu bạn muốn đi sâu hơn vào JavaScript trước khi bắt đầu TypeScript, khóa Lập Trình JavaScript Nâng Cao cover kỹ các khái niệm nền tảng như closure, prototype, reference types - những thứ giúp bạn hiểu TypeScript ở mức sâu hơn.


Strict mode trong team: enforce bằng ESLint, không bằng lời nói

Viết TypeScript tốt không đủ nếu teammate commit code bỏ qua tất cả. Cần tooling để enforce.

Setup mình recommend cho dự án lớn:

1. ESLint với @typescript-eslint

npm install -D @typescript-eslint/parser @typescript-eslint/eslint-plugin

Các rule quan trọng nhất:

{
  "rules": {
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/no-non-null-assertion": "warn",
    "@typescript-eslint/explicit-function-return-type": "warn",
    "@typescript-eslint/no-unused-vars": "error"
  }
}

2. Pre-commit hook với Husky

Không ai muốn bị chặn commit, nhưng cũng không ai muốn review PR với 50 TypeScript errors. Chạy type-check trước khi push:

# package.json scripts
"type-check": "tsc --noEmit",
"pre-push": "npm run type-check && npm run lint"

344403 Pre-commit hook chặn lỗi trước khi vào main branch - đau ngay, đỡ đau về sau

3. CI/CD pipeline

Husky chỉ chạy ở máy developer. Ai đó có thể bypass. CI/CD là lớp cuối:

# GitHub Actions
- name: Type check
  run: npm run type-check
- name: Lint
  run: npm run lint

Thực tế mình thấy: team nào setup CI/CD type-check từ đầu thì số bug production giảm rõ rệt trong 2-3 sprint đầu. Không phải vì người dùng TypeScript giỏi hơn - mà vì tooling buộc phải sửa lỗi trước khi merge.

Tải checklist đầy đủ để setup TypeScript cho dự án lớn: Tải checklist setup TypeScript

Trang web dùng cookie để hoạt động và đo hiệu quả quảng cáo. Xem chính sách bảo mật