Mẹo dùng TypeScript cho codebase lớn
Nội dung bài viết

TypeScript phát huy tác dụng rõ nhất trên codebase lớn, nhưng chỉ khi bạn dùng nó một cách có chủ đích. Một dự án rải đầy any và ép kiểu thì về bản chất vẫn là JavaScript, chỉ thêm bước biên dịch.

Đây là những thói quen tôi thấy tạo ra khác biệt thực sự.

Bật strict ngay từ ngày đầu

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true
  }
}

Bật strict trên dự án mới là chuyện dễ. Bật nó trên dự án đã chạy hai năm là một dự án riêng.

noUncheckedIndexedAccess đáng chú ý nhất trong nhóm trên. Nó khiến việc truy cập phần tử mảng trả về T | undefined, phản ánh đúng thực tế:

const users: User[] = [];
const first = users[0];   // User | undefined, không phải User
console.log(first.name);  // Lỗi biên dịch, đúng như mong đợi

Không có cờ này, TypeScript vui vẻ nói users[0]User kể cả khi mảng rỗng, và bạn nhận lỗi lúc chạy thay vì lúc build.

Tránh any, dùng unknown

any tắt toàn bộ kiểm tra kiểu và có tính lây lan: một giá trị any trôi qua nhiều tầng hàm sẽ vô hiệu hóa kiểm tra ở mọi chỗ nó đi qua.

Khi thật sự chưa biết kiểu là gì, unknown mới là lựa chọn đúng:

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data: unknown = await res.json();

  // Buộc phải kiểm tra trước khi dùng
  return userSchema.parse(data);
}

unknown không cho bạn làm gì với giá trị cho tới khi thu hẹp nó lại. Đó chính là điều bạn muốn với dữ liệu từ bên ngoài.

Kiểm tra ở ranh giới hệ thống

Đây là điểm tôi cho là quan trọng nhất. TypeScript chỉ tồn tại lúc biên dịch. Khi dữ liệu đến từ API, database, biến môi trường hay localStorage, không có gì đảm bảo nó khớp với kiểu bạn khai báo.

Viết const user = await res.json() as User là nói dối với chính mình.

Cách làm đúng là kiểm tra tại ranh giới bằng thư viện như Zod:

import { z } from "zod";

const userSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
  createdAt: z.coerce.date(),
});

type User = z.infer<typeof userSchema>;

z.infer sinh ra kiểu TypeScript từ chính schema, nên bạn chỉ khai báo một lần và không bao giờ lo kiểu bị lệch với logic kiểm tra.

Astro Content Collections dùng đúng cách tiếp cận này cho frontmatter Markdown, và đó là một trong những lý do tôi thích nó.

Mô hình hóa domain bằng discriminated union

Đây là công cụ mạnh nhất mà TypeScript cho bạn, và cũng là thứ bị dùng ít nhất.

Thay vì một object với đủ thứ trường tùy chọn:

// Cách này cho phép các trạng thái vô nghĩa tồn tại
interface RequestState {
  loading?: boolean;
  data?: User;
  error?: string;
}

Hãy mô tả các trạng thái thực sự có thể xảy ra:

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string };

Khác biệt nằm ở chỗ: kiểu thứ nhất cho phép tồn tại trạng thái loading: true mà đồng thời có cả error, một tình huống vô nghĩa. Kiểu thứ hai khiến trạng thái đó không thể biểu diễn được.

Và TypeScript tự thu hẹp kiểu theo trường phân biệt:

function render(state: RequestState) {
  switch (state.status) {
    case "loading":
      return <Spinner />;
    case "success":
      return <Profile user={state.data} />;  // data chắc chắn tồn tại
    case "error":
      return <Alert message={state.error} />;
    case "idle":
      return null;
  }
}

Nguyên tắc chung: khiến các trạng thái sai trở nên không thể biểu diễn. Khi kiểu dữ liệu không cho phép một tình huống xảy ra, bạn không cần viết test cho tình huống đó.

Ép kiểm tra đầy đủ mọi nhánh

Kết hợp union với một hàm nhỏ để bảo đảm bạn không quên nhánh nào:

function assertNever(value: never): never {
  throw new Error(`Trường hợp chưa xử lý: ${JSON.stringify(value)}`);
}

function render(state: RequestState) {
  switch (state.status) {
    case "idle":    return null;
    case "loading": return <Spinner />;
    case "success": return <Profile user={state.data} />;
    default:        return assertNever(state);
  }
}

Đoạn trên không biên dịch được vì thiếu nhánh error. Ngày mai khi ai đó thêm một trạng thái mới vào union, trình biên dịch sẽ chỉ ra mọi chỗ cần cập nhật. Đây là cách biến trình biên dịch thành danh sách việc cần làm.

Result thay cho throw

Trong hầu hết các luồng nghiệp vụ, kiểu Result rõ ràng hơn việc throw rồi catch:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function findUser(id: string): Promise<Result<User, "not_found" | "db_error">> {
  // ...
}

Lý do: chữ ký hàm cho biết nó có thể thất bại và thất bại theo cách nào. Với throw, thông tin đó không nằm trong kiểu, và người gọi phải đọc mã nguồn hoặc chờ tới lúc chạy mới biết.

Tôi không dùng cách này cho mọi thứ. Lỗi lập trình như đối số không hợp lệ thì cứ throw. Nhưng những thất bại có thể lường trước, kiểu không tìm thấy bản ghi hay validate không qua, nên nằm trong kiểu trả về.

Đặt kiểu gần với dữ liệu

Đặt định nghĩa kiểu ngay cạnh module sở hữu dữ liệu đó. Những file types.ts dùng chung cho cả dự án luôn mục ruỗng theo thời gian: chúng phình to, tích tụ kiểu không ai còn dùng, và tạo ra phụ thuộc vòng.

features/
  booking/
    types.ts        <- kiểu liên quan tới đặt phòng
    api.ts
    components/
  payment/
    types.ts        <- kiểu liên quan tới thanh toán
    api.ts

Chỉ nên có một file kiểu dùng chung cho những thứ thực sự dùng chung toàn cục, và nó nên rất nhỏ.

Vài mẹo nhỏ nhưng hữu ích

Dùng satisfies thay cho gán kiểu khi muốn vừa kiểm tra vừa giữ kiểu suy luận cụ thể:

const routes = {
  home: "/",
  blog: "/blog",
} satisfies Record<string, string>;

// routes.home có kiểu "/" chứ không phải string

as const cho dữ liệu cố định:

const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number];  // "admin" | "editor" | "viewer"

Danh sách và kiểu luôn đồng bộ vì kiểu được sinh ra từ chính danh sách.

Đừng lạm dụng generic. Một hàm với bốn tham số kiểu và ràng buộc lồng nhau thường là dấu hiệu bạn đang giải quyết sai vấn đề. Code kiểu dễ đọc quan trọng hơn code kiểu thông minh, giống hệt như với code thường.

Những thói quen nhỏ và nhất quán sẽ tích lũy thành một codebase mà sáu tháng sau bạn vẫn dám sửa.