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?
- Bắt đầu từ dữ liệu và hàm
- Suy luận kiểu và thu hẹp kiểu
- Mô hình dữ liệu bằng
interface,typevà generics - Null safety với
strictNullChecks - Đặt TypeScript vào mã Angular
- Lỗi thường gặp và các đánh đổi
- Quy trình áp dụng và tóm tắt
- Bước tiếp theo
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.
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:
- Giá trị này đến từ đâu và thực tế có thể có những dạng nào?
- Nơi dùng cần điều kiện nào để hoạt động an toàn?
- 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ĩa | Khi dùng trong Angular |
|---|---|---|
unknown | Có giá trị nhưng chưa biết hình dạng | Dữ 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 |
any | Tắ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 |
void | Hà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 |
never | Hàm không thể kết thúc bình thường, hoặc nhánh không thể xảy ra | Kiể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à
nullnhư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à readonlyTypeScript 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ĩa | Ví dụ thực tế |
|---|---|---|
Partial<T> | Mọi property của T thành tùy chọn | Payload cập nhật một phần profile |
Required<T> | Mọi property thành bắt buộc | Dữ liệu sau bước chuẩn hóa đã có đủ trường |
Pick<T, K> | Chỉ lấy các property K | Model hiển thị một hàng bảng |
Omit<T, K> | Bỏ các property K | Payload tạo mới không có id do server sinh |
Readonly<T> | Các property chỉ đọc | Snapshot state không được sửa trực tiếp |
Record<K, V> | Object có key kiểu K, value kiểu V | Bả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 | undefinedMộ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ùng | Ví dụ |
|---|---|---|
string | Luôn có chuỗi | Tên sản phẩm sau khi đã chuẩn hóa |
string | null | Có thể cố ý không có giá trị | API lưu avatar là null khi người dùng chưa tải ảnh |
string | undefined | Có thể chưa được truyền hoặc chưa gán | Kết quả tìm key không tồn tại trong object |
name?: string | Property có thể vắng mặt; khi đọc là string | undefined | Tù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,totalvàstatustheoOrder. selectedOrderIdkhởi đầu chưa chọn gì nên làstring | null, không giả vờ là chuỗi rỗng.computedsuy luậnbooleanchoisCancelledvàstringchoformattedTotaltừ biểu thức trả về.- Template được Angular type-check; gọi
order().customerNamehợ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ầng | Câu hỏi nó trả lời | Ví dụ kiểu |
|---|---|---|
| DTO | Server đã gửi gì? | UserDto với display_name: string | null |
| Model UI/domain | Giao diện cần dữ liệu nào, dưới dạng nào? | User với displayName: string và createdAt: Date |
| State | Dữ 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.
localStoragechứa JSON từ phiên bản ứng dụng trước.- Ngày chuỗi không parse được thành
Datehợ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:
- 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.
- Mô tả hình dạng đúng tại ranh giới. Dùng
interface/type, literal union vànull/undefinedtheo ý nghĩa thật. - 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.
- 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.
- Thu hẹp trước khi truy cập. Dùng
if,@if, type guard hoặcswitch; không dùngas/!như cách mặc định để qua lỗi. - Giữ generic khi có quan hệ tái sử dụng. Dùng
Tcho helper, wrapper API hoặc collection dùng cho nhiều model; không generic hóa domain cụ thể chỉ vì có thể. - Để 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.
unknownbuộc bạn kiểm tra dữ liệu;anytắt lớp bảo vệ.asvà!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,undefinedvà 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.