Đ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

Vì sao TypeScript là mặc định của web dev 2026? Lộ trình chuyển từ JS sang TS

Ếch Trendy
Ếch Trendy

5 giờ tới · 10 phút đọc

Tại sao TypeScript thắng?

Mình nhớ hồi 2020, mình gõ npm install --save-dev typescript xong ngồi nhìn file tsconfig.json không biết bắt đầu từ đâu. Đồng nghiệp bảo "TypeScript chỉ làm khó thêm thôi". Mình tin theo và bỏ qua luôn.

Rồi 6 tháng sau, mình nhận bug report: production crash vì undefined is not a function. Cái bug mà TypeScript đã bắt được ngay lúc viết code.

Hiện tại, 95% of top 100 npm packages had TypeScript support in 2024, with 68% of JS developers using TypeScript according to State of JS 2025 survey (State of JS 2025 Survey and npm Package-Pulse Dataset) dự án lớn trên GitHub đã dùng TypeScript. Đây không phải trend - đây là sự thay đổi cơ bản trong cách viết JS.

347373 JS không báo lỗi cho đến khi user gặp bug - TS bắt lỗi ngay lúc viết code

Lý do TS thắng không phải vì nó "fancy" hơn. Lý do thực tế hơn nhiều:

Autocomplete chính xác. IDE biết chính xác một object có property gì, function nhận kiểu dữ liệu gì. Không cần mở docs mỗi 5 phút.

Refactor không sợ. Đổi tên một function? TS sẽ báo tất cả chỗ cần update. JS thì chúc may mắn với Ctrl+F.

Tự document hóa code. Type annotations là documentation sống - không bao giờ outdated vì nếu outdated thì code không compile được.

Với các dự án lớn có nhiều người, những lợi ích này nhân lên theo số lượng developer. Đó là lý do tại sao hầu hết công ty tech lớn đã chuyển sang TS từ vài năm trước.


Setup TypeScript đúng cách từ đầu

Cài TS thì dễ. Cấu hình đúng mới là vấn đề. Mình thấy nhiều người copy paste tsconfig.json từ StackOverflow mà không hiểu từng option nghĩa là gì.

# Cài TypeScript và ts-node cho development
npm install --save-dev typescript ts-node @types/node

# Tạo tsconfig mặc định
npx tsc --init

347374 Đừng bỏ qua tsconfig - đây là nơi quyết định TS strict đến đâu

File tsconfig.json quan trọng nhất với các option sau:

{
  "compilerOptions": {
    // Bật chế độ strict - khuyến khích cho dự án mới
    "strict": true,

    // Target ES2020+ để dùng được optional chaining, nullish coalescing
    "target": "ES2020",

    // Module system - dùng ESNext cho Vite/bundler hiện đại
    "module": "ESNext",
    "moduleResolution": "bundler",

    // Source maps để debug dễ hơn
    "sourceMap": true,

    // Thư mục output
    "outDir": "./dist",

    // Thư mục source
    "rootDir": "./src",

    // Cho phép import JSON
    "resolveJsonModule": true,

    // Bắt buộc xử lý null/undefined
    "strictNullChecks": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Option strict: true bật 8 flag khác nhau cùng lúc, trong đó quan trọng nhất là strictNullChecks. Với option này, bạn không thể gán null hay undefined cho biến không khai báo kiểu nullable - chính xác là những bug phổ biến nhất trong JS.

Nếu bạn đang học JavaScript và muốn có nền tảng vững trước khi học TS, khóa Lập Trình JavaScript Nâng Cao trên F8 cover closure, prototype và cách JS thực sự hoạt động - những thứ sẽ giúp bạn hiểu TS sâu hơn nhiều.


Type annotations cơ bản và những thứ hay bị nhầm

Phần này mình sẽ đi thẳng vào những pattern thực tế - không phải lý thuyết string, number, boolean mà docs nào cũng có.

Union types - hay dùng hơn bạn nghĩ:

// Thay vì dùng any, dùng union type cụ thể
type Status = 'loading' | 'success' | 'error';
type ID = string | number;

function fetchUser(id: ID) {
  // TS biết id có thể là string hoặc number
  if (typeof id === 'string') {
    // Trong block này, TS biết id là string
    console.log(id.toUpperCase());
  }
}

Interface vs Type - khi nào dùng cái nào:

// Interface: dùng cho object shapes, có thể extend sau
interface User {
  id: number;
  name: string;
  email?: string; // Optional property
}

// Type alias: dùng cho union, intersection, primitives
type ApiResponse<T> = {
  data: T;
  status: number;
  message: string;
};

// Intersection type - combine 2 types
type AdminUser = User & { role: 'admin'; permissions: string[] };

347375 Interface cho phép extend sau khi khai báo, Type alias thì không - chọn đúng từ đầu tiết kiệm refactor

Generics - đừng sợ cái dấu <T>:

// Hàm generic: làm việc với bất kỳ kiểu nào nhưng vẫn type-safe
function getFirst<T>(arr: T[]): T | undefined {
  return arr[0];
}

const firstNum = getFirst([1, 2, 3]); // TS biết đây là number | undefined
const firstStr = getFirst(['a', 'b']); // TS biết đây là string | undefined

// Generic với constraint: T phải có property id
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

Type narrowing - tính năng ít được nhắc đến nhưng cực kỳ hữu ích:

type Cat = { type: 'cat'; meow: () => void };
type Dog = { type: 'dog'; bark: () => void };
type Animal = Cat | Dog;

function makeSound(animal: Animal) {
  // Discriminated union - TS thu hẹp type dựa vào giá trị runtime
  if (animal.type === 'cat') {
    animal.meow(); // TS biết đây là Cat
  } else {
    animal.bark(); // TS biết đây là Dog
  }
}

Discriminated union là pattern cực kỳ mạnh khi làm việc với API responses có nhiều dạng khác nhau.


Schema validation với Zod và Valibot

Type annotations chỉ hoạt động lúc compile time. Vấn đề là dữ liệu từ API, form input hay localStorage không có ai kiểm tra kiểu lúc runtime.

Đây là chỗ Zod và Valibot vào.

347376 TypeScript bảo vệ lúc viết code, Zod/Valibot bảo vệ lúc chạy - cần cả hai

Zod - battle-tested, ecosystem lớn:

import { z } from 'zod';

// Khai báo schema một lần, dùng cho cả validation và type inference
const UserSchema = z.object({
  id: z.number().positive(),
  name: z.string().min(2).max(50),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest']),
  createdAt: z.string().datetime().optional(),
});

// Tự động lấy TypeScript type từ schema - không cần khai báo 2 lần!
type User = z.infer<typeof UserSchema>;

// Validate API response
async function getUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const raw = await response.json();

  // Parse và validate cùng lúc - throw error nếu không hợp lệ
  return UserSchema.parse(raw);
}

// Hoặc dùng safeParse để xử lý lỗi không throw
const result = UserSchema.safeParse(rawData);
if (result.success) {
  console.log(result.data); // Đã được validate, type là User
} else {
  console.error(result.error.issues); // Mảng lỗi chi tiết
}

Valibot - nhẹ hơn Zod nhỏ hơn đáng kể so với Zod:

import * as v from 'valibot';

// Cú pháp khác một chút nhưng concept tương tự
const UserSchema = v.object({
  id: v.pipe(v.number(), v.minValue(1)),
  name: v.pipe(v.string(), v.minLength(2), v.maxLength(50)),
  email: v.pipe(v.string(), v.email()),
});

type User = v.InferOutput<typeof UserSchema>;

Chọn Zod nếu dự án đã có ecosystem liên quan (tRPC, React Hook Form, Drizzle). Chọn Valibot nếu bundle size là ưu tiên - đặc biệt quan trọng với mobile web.


Migration guide: từng bước chuyển JS sang TS mà không break

Đây là phần quan trọng nhất. Mình đã migrate vài dự án JS sang TS và có 2 cách tiếp cận:

Cách 1 - Big bang (không khuyến khích): Đổi tất cả file .js sang .ts một lần. Kết quả: 500 lỗi compile, team panic, revert về JS.

Cách 2 - Incremental (đúng cách): Chạy song song JS và TS, migrate từng file.

347377 Migration từng bước giúp dự án luôn chạy được trong quá trình chuyển đổi

Bước 1: Setup allowJs để JS và TS cùng tồn tại

// tsconfig.json
{
  "compilerOptions": {
    // Cho phép file .js tồn tại trong dự án TS
    "allowJs": true,

    // Kiểm tra JS file bằng TS rules (optional, bật sau)
    "checkJs": false,

    // Bắt đầu với strict: false, bật dần sau
    "strict": false,

    "noImplicitAny": false
  }
}

Bước 2: Đổi file từ dễ đến khó

Thứ tự migrate hợp lý:

  1. Utility functions - thuần logic, ít dependency
  2. Type definitions - tạo types/ folder, định nghĩa interfaces
  3. API layer - đây là nơi có nhiều bug nhất do dữ liệu không đoán trước được
  4. Components/Controllers - cuối cùng, sau khi đã có types

Bước 3: Handle third-party libraries

# Hầu hết thư viện phổ biến có @types package riêng
npm install --save-dev @types/lodash @types/express

# Kiểm tra types có sẵn chưa
npx npx tsd check

Nếu thư viện không có types:

// Tạo file declarations: src/types/some-library.d.ts
declare module 'legacy-library' {
  // Khai báo những gì bạn dùng
  export function doSomething(input: string): void;
  export const version: string;

  // Hoặc khai báo tạm để không bị lỗi
  const lib: any;
  export default lib;
}

Bước 4: Bật strict dần dần

// Thay vì bật strict: true một lần, bật từng flag
{
  "compilerOptions": {
    // Tuần 1: bắt implicit any
    "noImplicitAny": true,

    // Tuần 2: bật null checks
    "strictNullChecks": true,

    // Tuần 3: bật các flag còn lại
    "strictFunctionTypes": true,
    "strictPropertyInitialization": true
  }
}

Cách này giúp team không bị overwhelmed với 500 lỗi cùng một lúc. Mỗi tuần fix một loại lỗi, dần dần codebase sẽ sạch hẳn.


Best practices: any, unknown, và cách viết type-safe code

Phần này mình chia sẻ những gì mình học được sau vài năm viết TS - không phải lý thuyết, là kinh nghiệm thực tế.

any vs unknown - khác nhau hoàn toàn

// any: tắt hoàn toàn type checking - TRÁNH dùng
function processAny(data: any) {
  data.nonExistentMethod(); // TS không báo lỗi, runtime crash
  const x: number = data; // TS không báo lỗi
}

// unknown: type-safe alternative cho any
function processUnknown(data: unknown) {
  // data.nonExistentMethod(); // TS báo lỗi - ĐÚNG

  // Phải narrow type trước khi dùng
  if (typeof data === 'string') {
    console.log(data.toUpperCase()); // OK - TS biết đây là string
  }

  if (data instanceof Error) {
    console.error(data.message); // OK
  }
}

Khi nào dùng any hợp lý:

  • Trong quá trình migration (tạm thời, có TODO comment)
  • Khi viết type cho thư viện quá phức tạp không xứng đáng mất thời gian
  • Test files (đôi khi)

Không bao giờ dùng any:

  • API response (dùng Zod hoặc khai báo type cụ thể)
  • Event handlers
  • Shared utility functions

347378 unknown yêu cầu bạn kiểm tra type trước khi dùng - đây là điểm khác biệt quan trọng với any

Một số pattern type-safe hay dùng

// Readonly để tránh mutation ngoài ý muốn
const config: Readonly<{ apiUrl: string; timeout: number }> = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
};
// config.apiUrl = '...'; // Lỗi compile!

// Satisfies operator (TS 4.9+) - validate type mà không mất inference
const palette = {
  red: [255, 0, 0],
  green: '#00ff00',
} satisfies Record<string, string | number[]>;

// palette.red vẫn là number[], không phải string | number[]
// Nếu dùng 'as', TS sẽ cast và mất type info chi tiết

// Template literal types
type EventName = 'click' | 'focus' | 'blur';
type HandlerName = `on${Capitalize<EventName>}`;
// HandlerName = 'onClick' | 'onFocus' | 'onBlur'

Checklist trước khi merge

Mình có thói quen check 3 thứ này trước khi push code TS:

  1. Không có any mới (ngoại lệ phải có comment giải thích)
  2. Mọi async function đều có return type rõ ràng
  3. Mọi API boundary đều có Zod/Valibot validation

Nếu bạn đang build dự án React với TS, khóa Xây Dựng Website với ReactJS trên F8 có cover cách dùng React + TypeScript trong môi trường thực tế.