Angular Learning
Nền tảng Angular

TypeScript cần thiết

Nền tảng TypeScript cần dùng để đọc, viết và tránh lỗi phổ biến trong ứng dụng Angular.

Phạm vi bài học

Đây không phải là tài liệu đầy đủ về mọi tính năng của TypeScript. Mục tiêu là xây dựng phần kiến thức dùng hằng ngày trong Angular: mô tả dữ liệu, để TypeScript suy luận, thu hẹp kiểu an toàn, thiết kế API bằng type/generic và xử lý null trước khi dữ liệu đi vào template.

Mục lục

Vì sao Angular cần TypeScript?

JavaScript cho phép viết giao diện rất nhanh, nhưng không yêu cầu chương trình mô tả dữ liệu mà nó mong đợi. Ví dụ, một component có thể giả định user.name luôn là chuỗi, trong khi API thực tế trả name: null. Lỗi chỉ xuất hiện khi người dùng mở đúng màn hình với đúng dữ liệu đó.

TypeScript là JavaScript có thêm hệ thống kiểu tĩnh. Trước khi ứng dụng chạy, compiler đối chiếu mã với các kiểu đã mô tả và chỉ ra các mâu thuẫn có thể thấy được. Angular dùng TypeScript cho class component, service, dependency injection, forms và hầu hết API công khai. Vì vậy, biết TypeScript giúp bạn hiểu Angular chứ không chỉ giúp làm hết lỗi đỏ trong editor.

direction: right

n0: "API / input / form" {
  width: 168
  height: 58
}

n1: "Kiểu TypeScript\nMô tả dữ liệu được\nphép" {
  width: 168
  height: 79
}

n2: "Component + service\nXử lý dữ liệu" {
  width: 176
  height: 58
}

n3: "Template hiển thị\nđúng nhánh" {
  width: 160
  height: 58
}

n0 -> n1
n1 -> n2
n2 -> n3
API / input / formKiểu TypeScriptMô tả dữ liệu đượcphépComponent + serviceXử lý dữ liệuTemplate hiển thịđúng nhánh
API / input / formKiểu TypeScriptMô tả dữ liệu đượcphépComponent + serviceXử lý dữ liệuTemplate hiển thịđúng nhánh
TypeScript mô tả dữ liệu để code và template dùng

Kiểu là hợp đồng, không phải dữ liệu lúc chạy

Kiểu là một hợp đồng phát triển. Khi khai báo price: number, bạn nói với compiler và đồng đội rằng mã này kỳ vọng một số. Kiểu giúp phát hiện việc cộng nhầm chuỗi với số, gọi phương thức không tồn tại hoặc quên xử lý giá trị rỗng.

Tuy nhiên, phần lớn kiểu TypeScript bị xóa khi build thành JavaScript. Trình duyệt không tự kiểm tra API có thật sự trả number hay không. Nếu nguồn dữ liệu nằm ngoài mã của bạn — HTTP, localStorage, query string hoặc người dùng nhập — vẫn phải kiểm tra dữ liệu ở runtime. Phần Type guard ở ranh giới dữ liệu sẽ làm rõ điểm này.

Đọc lỗi TypeScript theo hướng dữ liệu

Thông báo như Type 'string | null' is not assignable to type 'string' có vẻ dài, nhưng nó đang mô tả một đường đi dữ liệu rất cụ thể: nơi nhận chỉ chấp nhận chuỗi, còn nơi gửi có thể là chuỗi hoặc null.

Đừng sửa lỗi bằng cách thêm as string ngay lập tức. Hãy lần theo ba câu hỏi:

  1. Giá trị này đến từ đâu và thực tế có thể có những dạng nào?
  2. Nơi dùng cần điều kiện nào để hoạt động an toàn?
  3. Nên kiểm tra, chuyển đổi hay thay đổi hợp đồng kiểu ở ranh giới nào?

Ví dụ, nếu tên người dùng từ API có thể rỗng nhưng nhãn trên UI luôn cần chữ, hãy chuyển đổi tại hàm map dữ liệu thành displayName: dto.name ?? 'Chưa đặt tên'. Component sau đó làm việc với string đơn giản hơn.

Bắt đầu từ dữ liệu và hàm

Kiểu nguyên thủy, mảng và object

Các kiểu cơ bản xuất hiện liên tục trong Angular là string, number, boolean, null và undefined. Mảng có thể viết Product[] hoặc Array<Product>; cách đầu ngắn hơn và thường dùng cho danh sách. Object nên có hình dạng rõ ràng thay vì dùng object, vì object chỉ nói rằng giá trị không phải primitive, không nói nó có trường nào.

type Product = {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
  tags: string[];
};

const product: Product = {
  id: 'p-42',
  name: 'Bàn phím',
  price: 1_290_000,
  inStock: true,
  tags: ['phụ kiện', 'cơ học'],
};

const productNames: string[] = ['Chuột', 'Màn hình'];

Dấu : trong name: string mô tả kiểu, còn dấu = gán giá trị. Có thể đọc câu const product: Product = ... là: biến product có hợp đồng Product, và giá trị bên phải phải tuân theo hợp đồng đó.

const không làm object bất biến sâu. Nó chỉ ngăn việc gán lại biến product sang object khác. product.name = 'Tên mới' vẫn hợp lệ nếu trường không được đánh dấu readonly.

Hàm, tham số và giá trị trả về

Hãy khai báo kiểu cho tham số vì hàm cần biết nó được phép nhận gì. Với hàm public, hàm phức tạp hoặc điểm ranh giới giữa các lớp, ghi rõ kiểu trả về giúp hợp đồng ổn định khi nội dung hàm thay đổi.

function formatPrice(price: number, currency: 'VND' | 'USD' = 'VND'): string {
  return new Intl.NumberFormat('vi-VN', {
    style: 'currency',
    currency,
    maximumFractionDigits: currency === 'VND' ? 0 : 2,
  }).format(price);
}

function applyDiscount(price: number, percent: number): number {
  return price * (1 - percent / 100);
}

formatPrice luôn trả string, còn applyDiscount luôn trả number. Nếu một nhánh quên return, TypeScript sẽ báo lỗi vì không thể giữ lời hứa number. Kiểu trả về cũng giúp template biết formatPrice(product.price) là chuỗi có thể hiển thị.

Literal type và union cho tập giá trị hữu hạn

Khi một trường chỉ có vài trạng thái hợp lệ, dùng literal type thay vì string. Literal type là kiểu của chính một giá trị cụ thể, như 'draft'. Ghép nhiều literal bằng | tạo union type — một giá trị được phép là một trong các lựa chọn đó.

type OrderStatus = 'draft' | 'paid' | 'shipped' | 'cancelled';

type Order = {
  id: string;
  status: OrderStatus;
  total: number;
};

function canCancel(order: Order): boolean {
  return order.status === 'draft' || order.status === 'paid';
}

Nếu gán status: 'waiting', compiler báo lỗi ngay. Đây là lợi ích thực tế hơn việc chỉ có autocomplete: code không thể vô tình xử lý một trạng thái không nằm trong quy tắc nghiệp vụ đã mô tả.

unknown, any, never và void

Bốn kiểu này có ý nghĩa rất khác nhau:

KiểuÝ nghĩaKhi dùng trong Angular
unknownCó giá trị nhưng chưa biết hình dạngDữ liệu từ catch, JSON không đáng tin hoặc tích hợp bên ngoài; phải kiểm tra trước khi dùng
anyTắt kiểm tra kiểu cho giá trị đóChỉ là lối thoát tạm thời khi chuyển đổi mã cũ; tránh lan ra API mới
voidHàm hoàn tất nhưng người gọi không dùng giá trị trả vềHandler như save(): void hoặc callback chỉ gây side effect
neverHàm không thể kết thúc bình thường, hoặc nhánh không thể xảy raKiểm tra exhaustiveness của union và hàm luôn ném lỗi

unknown an toàn hơn any. Với unknown, dòng sau bị cấm vì compiler chưa biết đó có phải chuỗi không:

function normalizeSearchTerm(value: unknown): string {
  if (typeof value === 'string') {
    return value.trim();
  }

  return '';
}

Trong khi đó, any cho phép value.trim() mà không kiểm tra. Lỗi sẽ bị đẩy từ lúc biên dịch sang lúc người dùng chạy ứng dụng. Quy tắc thực hành là: nhận dữ liệu không rõ dưới dạng unknown, sau đó thu hẹp nó thành kiểu đáng tin.

Suy luận kiểu và thu hẹp kiểu

Để TypeScript suy luận khi thông tin đã rõ

TypeScript thường suy luận tốt từ giá trị khởi tạo. Viết kiểu lặp lại ở nơi hiển nhiên làm mã nặng hơn mà không thêm an toàn.

const pageSize = 20; // number
const isLoading = false; // boolean
const categories = ['book', 'electronics']; // string[]

const selectedIds = new Set<string>();

Hãy khai báo kiểu rõ khi một trong các tình huống sau xảy ra:

  • Biến được tạo trước, giá trị sẽ gán sau.
  • Bạn muốn giữ một hợp đồng rộng hơn hoặc hẹp hơn giá trị khởi tạo.
  • Hàm, property public, input, output hoặc service là API để mã khác sử dụng.
  • Giá trị ban đầu là null nhưng về sau có thể là object thật.
let activeOrder: Order | null = null;
const filters: Readonly<Record<string, string>> = {};

Nếu chỉ viết let activeOrder = null, TypeScript không có đủ thông tin để biết biến này sẽ nhận Order. Khai báo union giải thích đúng vòng đời: lúc đầu chưa có đơn hàng, sau khi tải xong sẽ có một Order hoặc vẫn không tìm thấy.

Narrowing bằng điều kiện và discriminated union

Narrowing là việc TypeScript thu hẹp union thành kiểu cụ thể dựa trên một kiểm tra mà nó hiểu. Các kiểm tra quen thuộc là typeof, instanceof, Array.isArray, toán tử in, so sánh literal và kiểm tra null.

function formatCustomerId(id: string | number): string {
  if (typeof id === 'number') {
    return `KH-${id.toString().padStart(6, '0')}`;
  }

  return id.trim().toUpperCase();
}

Trong nhánh typeof id === 'number', id là number; nhánh còn lại là string. Không cần ép kiểu, vì điều kiện đã chứng minh kiểu cho compiler.

Với state tải dữ liệu, một discriminated union thường rõ hơn nhiều cờ boolean rời rạc. Nó có một trường literal chung, ở đây là status, để quyết định hình dạng của từng nhánh.

type LoadState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string };

function getStateLabel(state: LoadState<Order[]>): string {
  switch (state.status) {
    case 'idle':
      return 'Chưa tải';
    case 'loading':
      return 'Đang tải...';
    case 'success':
      return `Đã tải ${state.data.length} đơn hàng`;
    case 'error':
      return `Không thể tải: ${state.message}`;
  }
}

Khi status là 'success', TypeScript biết data tồn tại. Khi là 'error', chỉ message tồn tại. Cách này loại bỏ trạng thái vô nghĩa như loading: true, data: [...] và error: '...' cùng lúc, trừ khi bạn chủ động định nghĩa nhánh cho trường hợp đó.

Để buộc switch phải xử lý mọi trạng thái khi union được mở rộng, dùng một hàm kiểm tra never:

function assertNever(value: never): never {
  throw new Error(`Trạng thái chưa được xử lý: ${JSON.stringify(value)}`);
}

function getStateLabelExhaustive(state: LoadState<Order[]>): string {
  switch (state.status) {
    case 'idle': return 'Chưa tải';
    case 'loading': return 'Đang tải...';
    case 'success': return `Đã tải ${state.data.length} đơn hàng`;
    case 'error': return `Không thể tải: ${state.message}`;
    default: return assertNever(state);
  }
}

Nếu sau này thêm { status: 'refreshing' } mà quên sửa switch, state ở default không còn là never và compiler sẽ chỉ ra thiếu sót.

Type guard ở ranh giới dữ liệu

Một type guard là hàm kiểm tra runtime có kiểu trả về dạng value is SomeType. Nó vừa trả lời đúng/sai lúc chạy, vừa thông báo cho TypeScript rằng trong nhánh đúng, giá trị có thể xem là SomeType.

type ApiError = {
  message: string;
  code?: string;
};

function isApiError(value: unknown): value is ApiError {
  return typeof value === 'object'
    && value !== null
    && 'message' in value
    && typeof value.message === 'string';
}

function errorMessage(error: unknown): string {
  if (isApiError(error)) {
    return error.code ? `${error.code}: ${error.message}` : error.message;
  }

  return 'Đã xảy ra lỗi không xác định.';
}

catch nên nhận lỗi là unknown, vì bất kỳ mã nào cũng có thể throw chuỗi, object, Error hoặc giá trị khác. Type guard đặt ở đây cô lập phần không đáng tin; các component gọi errorMessage chỉ nhận lại string an toàn để hiển thị.

Ép kiểu không phải là kiểm tra dữ liệu

as là type assertion: bạn bảo compiler hãy tin một kiểu nào đó. Nó không chuyển đổi hay kiểm tra giá trị khi ứng dụng chạy.

const raw = JSON.parse('{"id": 42}');
const user = raw as { id: string; name: string };

// Compiler tin lời khẳng định, nhưng user.name vẫn là undefined lúc chạy.

Có lúc assertion hợp lý, chẳng hạn TypeScript không suy ra được một quan hệ mà bạn đã bảo đảm bằng API. Nhưng nó phải là bước cuối sau khi đã có bằng chứng, không phải công cụ chữa mọi lỗi kiểu. Với JSON và HTTP, hãy dùng type guard, schema validator hoặc một hàm map kiểm tra dữ liệu trước.

Cẩn thận với `as` trong template và component

as User có thể làm lỗi biên dịch biến mất, nhưng không làm API gửi đúng dữ liệu. Nếu phải assertion lặp lại ở nhiều nơi, hợp đồng dữ liệu hoặc ranh giới chuyển đổi của bạn đang thiếu.

Mô hình dữ liệu bằng interface, type và generics

Chọn interface hay type

Cả interface và type đều mô tả hình dạng object. Với model dữ liệu đơn giản, hai cách sau gần như tương đương:

interface Customer {
  id: string;
  name: string;
}

type CustomerRow = {
  id: string;
  name: string;
};

Một quy ước dễ giữ trong dự án Angular là dùng interface cho object contract có tên — DTO, entity, cấu hình, props/input — và dùng type khi cần ghép union, intersection, mapped type hoặc alias cho một kiểu khác. Đây là quy ước đọc mã, không phải luật bắt buộc.

interface CreateOrderRequest {
  customerId: string;
  note?: string;
}

type OrderStatus = 'draft' | 'paid' | 'shipped' | 'cancelled';
type OrderWithCustomer = Order & { customer: Customer };

interface hỗ trợ declaration merging, nghĩa là hai khai báo cùng tên có thể được TypeScript gộp lại. Điều này hữu ích khi mở rộng type từ thư viện, nhưng cũng là lý do nên tránh đặt interface chung chung ở global scope. type không declaration merge và linh hoạt hơn khi biểu diễn union. Điều quan trọng nhất là cả đội chọn quy ước nhất quán, thay vì tranh luận cú pháp ở mỗi file.

Thuộc tính tùy chọn, readonly và utility types

note?: string có nghĩa thuộc tính note có thể không có mặt. Khi đọc nó, TypeScript xem kết quả là string | undefined. Đừng hiểu nó là string | null; hai giá trị này truyền tải hai ý nghĩa khác nhau và được bàn kỹ ở phần null safety.

readonly ngăn thay đổi property qua tham chiếu có kiểu đó. Nó phù hợp với model đã chuẩn hóa hoặc dữ liệu input chỉ nên được đọc.

interface ProductFilter {
  readonly query: string;
  readonly categoryId?: string;
  readonly page: number;
}

const initialFilter: ProductFilter = {
  query: '',
  page: 1,
};

// initialFilter.page = 2; // Lỗi: page là readonly

TypeScript có các utility type hữu ích để tạo biến thể của model mà không sao chép danh sách trường:

Utility typeÝ nghĩaVí dụ thực tế
Partial<T>Mọi property của T thành tùy chọnPayload cập nhật một phần profile
Required<T>Mọi property thành bắt buộcDữ liệu sau bước chuẩn hóa đã có đủ trường
Pick<T, K>Chỉ lấy các property KModel hiển thị một hàng bảng
Omit<T, K>Bỏ các property KPayload tạo mới không có id do server sinh
Readonly<T>Các property chỉ đọcSnapshot state không được sửa trực tiếp
Record<K, V>Object có key kiểu K, value kiểu VBảng ánh xạ trạng thái sang nhãn
type CreateProductRequest = Omit<Product, 'id'>;
type ProductPreview = Pick<Product, 'id' | 'name' | 'price'>;
type ProductPatch = Partial<Pick<Product, 'name' | 'price' | 'inStock'>>;

const statusLabel: Record<OrderStatus, string> = {
  draft: 'Nháp',
  paid: 'Đã thanh toán',
  shipped: 'Đang giao',
  cancelled: 'Đã hủy',
};

Partial<T> không tự biến dữ liệu thành hợp lệ. Nó chỉ nói từng trường có thể thiếu. API cập nhật có thể chấp nhận payload partial, nhưng trước khi hiển thị hoặc lưu domain model hoàn chỉnh, bạn vẫn cần kiểm tra các điều kiện nghiệp vụ.

Generics: giữ quan hệ giữa input và output

Generic là một tham số kiểu. Thay vì viết nhiều hàm cho Product[], Order[] và Customer[], ta viết một hàm có T đại diện cho kiểu phần tử. Khi gọi, TypeScript thay T bằng kiểu cụ thể và giữ quan hệ đó đến đầu ra.

function firstItem<T>(items: readonly T[]): T | undefined {
  return items[0];
}

const firstProduct = firstItem<Product>([product]); // Product | undefined
const firstStatus = firstItem<OrderStatus>(['paid', 'shipped']); // OrderStatus | undefined

Một ví dụ quan trọng trong Angular là HttpClient.get<T>(). T mô tả dữ liệu mà mã gọi kỳ vọng nhận được, nên Observable sau đó giữ đúng kiểu dữ liệu:

// http.get<Product[]>('/api/products') có kiểu Observable<Product[]>

Generic không “xác thực” JSON từ server; nó chỉ giữ hợp đồng trong mã TypeScript. Nếu server trả sai, phần runtime vẫn cần chiến lược kiểm tra phù hợp.

Ràng buộc generic và keyof

Đôi khi T quá rộng. Nếu hàm cần đọc property id, hãy nói rõ điều đó qua constraint T extends { id: string }. keyof T tạo union từ các key của T, giúp hàm chỉ nhận property tồn tại thật.

function findById<T extends { id: string }>(items: readonly T[], id: string): T | undefined {
  return items.find((item) => item.id === id);
}

function sortBy<T, K extends keyof T>(items: readonly T[], key: K): T[] {
  return [...items].sort((left, right) =>
    String(left[key]).localeCompare(String(right[key])),
  );
}

const foundProduct = findById([product], 'p-42');
const sortedProducts = sortBy([product], 'name');

Không nên generic hóa mọi thứ. Nếu chỉ xử lý Product và quy tắc nghiệp vụ gắn chặt với product, viết Product trực tiếp thường dễ đọc hơn. Hãy dùng generic khi thật sự có một thuật toán hoặc contract tái sử dụng cho nhiều kiểu mà vẫn cần giữ liên hệ kiểu chính xác.

Null safety với strictNullChecks

tsconfig.json của dự án này bật strict: true; trong đó có strictNullChecks. Với thiết lập này, null và undefined không thể lặng lẽ đi vào nơi yêu cầu một giá trị thật. Đây là một trong những lớp bảo vệ có giá trị nhất cho UI bất đồng bộ.

Phân biệt null, undefined và thuộc tính tùy chọn

Ba trạng thái này liên quan nhưng không giống nhau:

Biểu diễnÝ nghĩa thường dùngVí dụ
stringLuôn có chuỗiTên sản phẩm sau khi đã chuẩn hóa
string | nullCó thể cố ý không có giá trịAPI lưu avatar là null khi người dùng chưa tải ảnh
string | undefinedCó thể chưa được truyền hoặc chưa gánKết quả tìm key không tồn tại trong object
name?: stringProperty có thể vắng mặt; khi đọc là string | undefinedTùy chọn name trong payload cập nhật

Chọn dựa trên ngữ nghĩa của nguồn dữ liệu. Nếu backend trả null, model DTO nên giữ null; đừng đổi thành optional chỉ để compiler bớt phàn nàn. Nếu bạn thiết kế API, hãy dùng nhất quán: field không gửi đi và field gửi giá trị null thường mang ý nghĩa khác nhau.

interface UserDto {
  id: string;
  display_name: string | null;
  avatar_url: string | null;
}

interface UpdateUserRequest {
  displayName?: string;
  avatarUrl?: string | null;
}

Trong UpdateUserRequest, không gửi avatarUrl nghĩa là không thay đổi avatar. Gửi avatarUrl: null nghĩa là xóa avatar. Kiểu thể hiện quy tắc đó ngay tại nơi lập payload.

Kiểm tra trước khi dùng và optional chaining

Khi giá trị có thể rỗng, kiểm tra rõ ràng trước khi truy cập property hoặc gọi phương thức. Điều kiện này vừa bảo vệ runtime vừa narrowing kiểu cho compiler.

function avatarAlt(user: UserDto | null): string {
  if (user === null) {
    return 'Chưa có người dùng';
  }

  return user.display_name ?? 'Người dùng chưa đặt tên';
}

Toán tử ?. dừng chuỗi truy cập và trả undefined nếu vế bên trái là null hoặc undefined. Toán tử ?? chỉ dùng giá trị dự phòng khi vế trái là null hoặc undefined.

const city = profile?.address?.city ?? 'Chưa cập nhật';
const quantity = formValue.quantity ?? 1;

Đừng thay ?? bằng || khi 0, false hoặc chuỗi rỗng là giá trị hợp lệ. quantity || 1 sẽ đổi cả 0 thành 1, trong khi quantity ?? 1 giữ nguyên 0.

Trong template Angular, control flow cũng làm hẹp kiểu. Ví dụ @if (user(); as currentUser) chỉ render nhánh bên trong khi signal user() có giá trị truthy, đồng thời đặt một biến local có thể dùng an toàn trong nhánh đó.

Tránh non-null assertion và giá trị mặc định sai nghĩa

Dấu ! sau biểu thức, như user!.name, là non-null assertion. Nó bảo compiler bỏ qua khả năng null/undefined. Nó không tạo ra user và sẽ ném lỗi nếu giả định sai.

// Rủi ro: route đang tải hoặc API trả 404 thì user vẫn là null.
const name = user!.name;

Chỉ dùng ! khi một bất biến đã được bảo đảm ở ngay gần đó mà compiler không nhìn thấy được. Trong UI, điều này hiếm hơn cảm giác ban đầu. Một nhánh if, một state union hoặc template @if thường thể hiện vòng đời rõ hơn.

Tương tự, đừng dùng giá trị mặc định chỉ để khử lỗi kiểu nếu nó làm sai nghiệp vụ. orderId ?? '' có thể biến “chưa có ID” thành request /orders/, khó truy vết hơn. Nếu ID là bắt buộc để gọi API, hãy dừng luồng, hiển thị lỗi hoặc điều hướng về trạng thái phù hợp.

Đặt TypeScript vào mã Angular

Các khái niệm trên trở nên hữu ích nhất khi chúng nằm đúng ở các ranh giới của Angular: input đi vào component, API đi vào service, giá trị form đi vào domain và event DOM đi vào handler.

Component: input, state và template

Ví dụ sau dùng input signal của Angular hiện đại. input.required<Order>() nói rằng component cha bắt buộc phải truyền một Order; trong class và template con, order() luôn trả Order, không phải Order | undefined.

import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';

type OrderStatus = 'draft' | 'paid' | 'shipped' | 'cancelled';

interface Order {
  id: string;
  customerName: string;
  total: number;
  status: OrderStatus;
}

@Component({
  selector: 'app-order-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <article>
      <h2>{{ order().customerName }}</h2>
      <p>Tổng tiền: {{ formattedTotal() }}</p>

      @if (isCancelled()) {
        <p role="status">Đơn hàng đã bị hủy.</p>
      } @else {
        <button type="button" (click)="select()">Chọn đơn hàng</button>
      }
    </article>
  `,
})
export class OrderCardComponent {
  readonly order = input.required<Order>();
  readonly selectedOrderId = signal<string | null>(null);

  readonly isCancelled = computed(() => this.order().status === 'cancelled');
  readonly formattedTotal = computed(() =>
    new Intl.NumberFormat('vi-VN', {
      style: 'currency',
      currency: 'VND',
      maximumFractionDigits: 0,
    }).format(this.order().total),
  );

  select(): void {
    this.selectedOrderId.set(this.order().id);
  }
}

Các kiểu ở đây trả lời các câu hỏi quan trọng:

  • Cha phải truyền đủ id, customerName, total và status theo Order.
  • selectedOrderId khởi đầu chưa chọn gì nên là string | null, không giả vờ là chuỗi rỗng.
  • computed suy luận boolean cho isCancelled và string cho formattedTotal từ biểu thức trả về.
  • Template được Angular type-check; gọi order().customerName hợp lệ vì input là required.

Nếu input là tùy chọn, hãy mô tả đúng điều đó bằng input<Order>(), sau đó xử lý undefined trong component hoặc template. Đừng khai báo input required rồi kỳ vọng nó vắng mặt ở một số trường hợp.

Service: DTO từ API và model cho giao diện

DTO (Data Transfer Object) là hình dạng dữ liệu truyền qua API. DTO không nhất thiết phù hợp để component hiển thị trực tiếp: API có thể dùng snake_case, cho phép null, trả ngày dưới dạng chuỗi hoặc chứa trường kỹ thuật không thuộc UI.

Tách DTO và model UI tạo ra một ranh giới chuyển đổi rõ ràng:

import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map, Observable } from 'rxjs';

interface UserDto {
  id: string;
  display_name: string | null;
  active: boolean;
  created_at: string;
}

interface User {
  id: string;
  displayName: string;
  isActive: boolean;
  createdAt: Date;
}

@Injectable({ providedIn: 'root' })
export class UserService {
  private readonly http = inject(HttpClient);

  getById(id: string): Observable<User> {
    return this.http.get<UserDto>(`/api/users/${id}`).pipe(
      map((dto) => this.toUser(dto)),
    );
  }

  private toUser(dto: UserDto): User {
    return {
      id: dto.id,
      displayName: dto.display_name ?? 'Chưa đặt tên',
      isActive: dto.active,
      createdAt: new Date(dto.created_at),
    };
  }
}

Sau toUser, phần UI không cần biết display_name từng có thể là null hay API dùng tên trường nào. Điều đó giảm số lần dùng ?. và ?? rải rác trong component/template. Nếu API không hoàn toàn đáng tin hoặc được quản lý bởi bên thứ ba, hãy xác thực unknown thành UserDto trước toUser; generic trong http.get<UserDto>() không tự làm việc này.

Đặt ranh giới chuyển đổi gần nguồn dữ liệu

Chuẩn hóa tên field, nullability và định dạng ngày trong service hoặc adapter. Component nên nhận model có ý nghĩa cho UI, thay vì lặp lại kiến thức về wire format của API.

Forms typed: biểu diễn giá trị có thể rỗng

Reactive Forms của Angular có generic để giá trị control phản ánh vòng đời thực. Mặc định, một FormControl có thể được reset về null; do đó control khởi tạo bằng chuỗi thường có kiểu string | null.

import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-search-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <label>
      Từ khóa
      <input [formControl]="query" />
    </label>
  `,
})
export class SearchFormComponent {
  readonly query = new FormControl('', { nonNullable: true });

  submit(): void {
    const term = this.query.value.trim(); // string, không phải string | null
    // Gọi service với term.
  }
}

nonNullable: true phù hợp khi null không có nghĩa nghiệp vụ và control nên reset về giá trị khởi tạo ''. Nếu “chưa chọn” có ý nghĩa thật, hãy giữ union có null và buộc submit xử lý nó:

readonly assigneeId = new FormControl<string | null>(null);

save(): void {
  const assigneeId = this.assigneeId.value;

  if (assigneeId === null) {
    return;
  }

  // assigneeId là string trong nhánh này.
}

Không có lựa chọn đúng cho mọi form. Chọn nonNullable khi giá trị khởi tạo luôn là giá trị hợp lệ; giữ null khi trạng thái chưa chọn cần được phân biệt với chuỗi rỗng.

Event và DOM: dùng kiểu sự kiện đúng cách

Trong template, Angular thường suy luận event cho handler. Khi phải xử lý Event tổng quát ở TypeScript, event.target chỉ có kiểu EventTarget | null; EventTarget không đảm bảo có property value.

onSearchInput(event: Event): void {
  const input = event.target;

  if (!(input instanceof HTMLInputElement)) {
    return;
  }

  console.log(input.value.trim());
}

instanceof HTMLInputElement là type guard runtime. Sau điều kiện, TypeScript biết input là phần tử input và có value. Tránh viết (event.target as HTMLInputElement).value nếu event có thể đến từ element khác; assertion ở đây che giấu đúng điều cần kiểm tra.

Với Angular Forms, thường tốt hơn khi lấy giá trị từ FormControl hoặc ngModel thay vì tự đọc DOM. Hãy dùng handler event trực tiếp khi chính event chứa thông tin cần thiết, như KeyboardEvent.key, DragEvent.dataTransfer hoặc thao tác DOM thật sự.

Lỗi thường gặp và các đánh đổi

Dùng any để làm im lỗi

Ví dụ sau có vẻ tiện nhưng làm mất autocomplete, refactor an toàn và kiểm tra null ở toàn bộ đường đi sau đó:

function showUserName(user: any): string {
  return user.profile.name.toUpperCase();
}

Cách sửa tốt không nhất thiết là viết một kiểu khổng lồ. Bắt đầu bằng phần hàm thật sự cần:

type UserWithProfile = {
  profile: {
    name: string;
  };
};

function showUserName(user: UserWithProfile): string {
  return user.profile.name.toUpperCase();
}

Nếu dữ liệu đến từ bên ngoài, nhận unknown và kiểm tra trước khi gọi hàm. any có thể cần thiết khi chuyển một thư viện JavaScript cũ, nhưng nên bị cô lập trong adapter có ghi chú rõ ràng; không để nó lan thành kiểu của service hoặc state chung.

Khai báo quá rộng hoặc quá hẹp

Khai báo quá rộng làm compiler không bảo vệ được quy tắc:

// Quá rộng: mọi chuỗi đều đi qua được.
interface LooseOrder {
  status: string;
}

Khai báo quá hẹp cũng gây hại nếu nó phủ nhận dữ liệu thật:

// Quá hẹp nếu API thực sự có thể trả null.
interface IncorrectUserDto {
  display_name: string;
}

Hãy mô tả chính xác ở từng ranh giới. DTO phản ánh wire format thật; model domain/UI phản ánh bất biến sau khi chuẩn hóa; state phản ánh các giai đoạn tải; payload form phản ánh điều người dùng được phép gửi. Kiểu tốt không phải kiểu phức tạp nhất mà là kiểu đúng ý nghĩa của tầng đang đứng.

Trộn DTO, state và dữ liệu hiển thị

Một lỗi thiết kế phổ biến là đưa nguyên phản hồi API vào component rồi để template tự xử lý null, chuyển snake_case, format ngày và quyết định trạng thái tải. Component dần chứa các field nửa chuẩn hóa và điều kiện khó đọc.

Thay vào đó, phân tách ba việc:

TầngCâu hỏi nó trả lờiVí dụ kiểu
DTOServer đã gửi gì?UserDto với display_name: string | null
Model UI/domainGiao diện cần dữ liệu nào, dưới dạng nào?User với displayName: string và createdAt: Date
StateDữ liệu đang ở giai đoạn nào?LoadState<User>

Sự tách này có thêm code map, nên không phải lúc nào cũng cần cho một API nội bộ rất nhỏ và ổn định. Nhưng khi API nhiều endpoint, có nhiều component dùng lại dữ liệu hoặc có yêu cầu xử lý lỗi/loading, chi phí map nhỏ hơn nhiều so với chi phí sửa điều kiện trùng lặp sau này.

Chỉ tin compiler mà quên kiểm tra runtime

TypeScript kiểm tra mã của bạn, không kiểm soát server, trình duyệt hay người dùng. Các ví dụ sau vẫn cần runtime validation hoặc xử lý lỗi dù kiểu có đẹp đến đâu:

  • API trả phiên bản payload cũ hoặc dữ liệu hỏng.
  • Query parameter có giá trị người dùng tự sửa trên URL.
  • localStorage chứa JSON từ phiên bản ứng dụng trước.
  • Ngày chuỗi không parse được thành Date hợp lệ.
  • Người dùng gửi form đã bị client sửa bằng DevTools.

Tại ranh giới không đáng tin, dùng schema validator (chẳng hạn Zod), type guard đủ chặt, hoặc kiểm tra thủ công tùy mức độ quan trọng. Với dữ liệu bảo mật và quy tắc nghiệp vụ, backend phải xác thực độc lập; TypeScript phía client không phải hàng rào bảo mật.

Quy trình áp dụng và tóm tắt

Checklist khi viết mã Angular

Khi thêm một component, service hoặc form, đi theo thứ tự sau để kiểu dẫn dắt thiết kế thay vì chỉ sửa lỗi sau cùng:

  1. Xác định nguồn dữ liệu. Nó là input, API, form, route hay state cục bộ? Dữ liệu bên ngoài bắt đầu là không đáng tin.
  2. Mô tả hình dạng đúng tại ranh giới. Dùng interface/type, literal union và null/undefined theo ý nghĩa thật.
  3. Chuẩn hóa sớm. Đổi DTO thành model UI trong service/adapter; không để template biết wire format nếu không cần.
  4. Biểu diễn state loại trừ nhau. Với luồng tải phức tạp, ưu tiên discriminated union thay vì nhiều boolean có thể mâu thuẫn.
  5. Thu hẹp trước khi truy cập. Dùng if, @if, type guard hoặc switch; không dùng as/! như cách mặc định để qua lỗi.
  6. Giữ generic khi có quan hệ tái sử dụng. Dùng T cho helper, wrapper API hoặc collection dùng cho nhiều model; không generic hóa domain cụ thể chỉ vì có thể.
  7. Để compiler kiểm tra template. Kiểu input, form control và property component cần phản ánh vòng đời thực để template không đọc dữ liệu quá sớm.

Tóm tắt cần nhớ

  • TypeScript là hợp đồng phát triển; nó phát hiện mâu thuẫn trước runtime nhưng không xác thực dữ liệu bên ngoài.
  • Hãy để compiler suy luận ở chỗ hiển nhiên, và ghi kiểu rõ tại API, property, input hoặc giá trị khởi tạo null.
  • Union literal mô tả tập trạng thái hợp lệ. Discriminated union giúp state loading/error/data không mâu thuẫn.
  • unknown buộc bạn kiểm tra dữ liệu; any tắt lớp bảo vệ. as và ! chỉ đổi niềm tin của compiler, không đổi dữ liệu thật.
  • Dùng interface/type để mô hình hóa từng tầng; dùng generic để giữ liên hệ kiểu giữa input và output.
  • Phân biệt null, undefined và property tùy chọn. Kiểm tra hoặc chuẩn hóa chúng trước khi template phụ thuộc vào giá trị.
  • Trong Angular, đặt kiểu tốt nhất ở các ranh giới: input component, service/API, reactive form và handler event.

Bước tiếp theo

On this page

Mục lụcVì sao Angular cần TypeScript?Kiểu là hợp đồng, không phải dữ liệu lúc chạyĐọc lỗi TypeScript theo hướng dữ liệuBắt đầu từ dữ liệu và hàmKiểu nguyên thủy, mảng và objectHàm, tham số và giá trị trả vềLiteral type và union cho tập giá trị hữu hạnunknown, any, never và voidSuy luận kiểu và thu hẹp kiểuĐể TypeScript suy luận khi thông tin đã rõNarrowing bằng điều kiện và discriminated unionType guard ở ranh giới dữ liệuÉp kiểu không phải là kiểm tra dữ liệuMô hình dữ liệu bằng interface, type và genericsChọn interface hay typeThuộc tính tùy chọn, readonly và utility typesGenerics: giữ quan hệ giữa input và outputRàng buộc generic và keyofNull safety với strictNullChecksPhân biệt null, undefined và thuộc tính tùy chọnKiểm tra trước khi dùng và optional chainingTránh non-null assertion và giá trị mặc định sai nghĩaĐặt TypeScript vào mã AngularComponent: input, state và templateService: DTO từ API và model cho giao diệnForms typed: biểu diễn giá trị có thể rỗngEvent và DOM: dùng kiểu sự kiện đúng cáchLỗi thường gặp và các đánh đổiDùng any để làm im lỗiKhai báo quá rộng hoặc quá hẹpTrộn DTO, state và dữ liệu hiển thịChỉ tin compiler mà quên kiểm tra runtimeQuy trình áp dụng và tóm tắtChecklist khi viết mã AngularTóm tắt cần nhớBước tiếp theo