All Posts
01-05-2024
06-11-2022
03-03-2022
12-01-2022
02-01-2022
20-11-2021
07-11-2021
31-10-2021
31-10-2021
20-11-2021
Bài này dành để ghi chú một số thứ mình thấy đáng lưu ý khi học và sử dụng TypeScript. Đây không phải là tutorial hướng dẫn sử dụng TS, bởi vậy bài này dành cho những bạn đã sử dụng TS và biết một số khái niệm thông dụng.
Trong TS, ta có các type cơ bản là:
5 type đầu, có lẽ ai cũng biết, chỉ có 3 loại sau mà chúng ta cần lưu ý là: unknown
, never
và any
.
unknown
: Đây là một type đặc biệt. Nếu xét về khía cạnh toán học, đây là type bao hàm tất cả các type còn lại trong danh sách trên. Nếu chúng ta khai báo một biến với type unknown
, nó có thể nhận bất kỳ value của bất cứ type nào. Nếu xét về ngữ nghĩa khi sử dụng, chúng ta sẽ dùng nó cho những biến có type chưa xác định tại thời điểm viết. Ví dụ như error
khi xử lý gọi request, nếu chúng ta muốn dùng nó thì cần phải thu hẹp nó về một type xác định trước khi dùng.
class ServerError { code: string; // constructor(...) {...}}try { // thực hiện một request} catch (e: unknown) { console.log(e.code); // Lỗi vì type của e chưa xác định, cần thu hẹp if (e instanceof ServerError) { console.log(e.code); // Hợp lệ, lúc này e đã thu hẹp về một type xác định là ServerError }}
never
: Đây cũng là một kiểu đặc biệt. Cũng xét về khía cạnh toán học, đây là type nhỏ nhất, với một value type never
, nó có thể được gán cho bất kỳ biến có type nào. Xét về khía cạnh ngữ nghĩa, type này ít khi được dùng trực tiếp, mà dùng để hệ thống bắt lỗi hoặc sử dụng trong generics.
let a: never = 'lorem';let b: number;b = a; // hợp lệa = b; // không hợp lệ
any
: Đây là một type đặc biệt mũ hai của Typescript. Nó là sự kết hợp của unknown và never. Tức là nếu một biến được khai báo với kiểu any, tức là biến đó có thể chứa bất kỳ giá trị nào, đồng thời, biến với kiểu any cũng có thể gán cho những kiểu dữ liệu khác. Kiểu này thường dùng cho những trường hợp chúng ta không biết đặt type thế nào cho biến, hoặc cũng không quan trọng type nữa (vì quá phức tạp và không cần thiết), đặc biệt dùng nhiều nếu migrate project từ JS sang TS.
let a: any = 'lorem';let b: number;b = a; // hợp lệa = b; // hợp lệ
Ngoài các types đã kể phía trên, có một type đặc biệt, được gọi là literal type.
// 1let num1 = 1;// 2const num2 = 1;
Đối với khai báo trường hợp số 1, kiểu của num1 sẽ là number
, bởi vì chúng ta đang khai báo với let, điều này dẫn đến num1 có thể được gán với một giá trị khác cùng kiểu.
Trong khi với trường hợp số 2, kiểu của num2 sẽ là 1
. Do được khai báo với const nên chắc chắn giá trị của num2 chỉ được giữ một giá trị duy nhất.
Vậy điều này có ý nghĩa gì? Hãy cùng xem ví dụ bên dưới:
function makeRequest(type = 'POST' | 'GET', data: any) { //... execute request}const request = { method: 'POST', data: { id: 1 },};/// Liệu rằng dòng dưới có hợp lệ hay không?makeRequest(request.method, request.data);
Hàm makeRequest
nhận 2 tham số, tham số đầu tiên là type, với kiểu chính là literal type: "POST" hoặc là "GET".
Đối với object request, chúng ta sẽ có 2 thuộc tính method và data. Tuy nhiên, dù khai báo với const
, nhưng request.method sẽ mang kiểu là string
bởi vì chúng ta vẫn có thể sửa nó. Ví dụ request.method = "abc"
.
Bởi vậy, việc gọi hàm makeRequest(request.method, request.data)
sẽ báo lỗi, vì kiểu sẽ không được chấp nhận.
Tuy nhiên, nếu chúng ta chắc chắn rằng mình sẽ không bao giờ thay đổi giá trị của object request, chúng ta có thể làm như sau:
// Sử dụng từ khóa as constconst request = { method: 'POST', data: { id: 1 },} as const;makeRequest(request.method, request.data);
Khi sử dụng const sau một giá trị, tức ta đã biến giá trị đó thành literal type.
Đối với việc khai báo kiểu cho hàm, chúng ta có rất nhiều cách:
// CÁCH 1function sum(a: number, b: numer): number { return a + b;}// CÁCH 2const sum = (a: number, b: number): number => { return a + b;};// CÁCH 3type SumFn = (a: number, b: number) => number;// -- hoặctype SumFn = { (a: number, b: number): number;};// -- hoặcinterface SumFn { (a: number, b: number): number;}const sum: SumFn = (a, b) => { return a + b;};// HÀM KHÔNG TRẢ VỀfunction log(a: number): void { console.log(a);}// Typing cho thisfunction clickHandler(this: HTMLButtonElement, event: Event) { // ...}
Vì hàm cũng là object, nên đôi khi chúng ta sẽ có trường hợp cần thêm thuộc tính cho hàm:
type MathFn = { (a: number, b: number): number; operator: string;};const sum: MathFn = (a, b) => a + b;sum.operator = '+';
Sử dụng Generics với hàm:
function arrayify2<Type>(a: Type): Array<Type> { return [a];}// hoặcconst arrayify = <Type extends unknown>(a: Type): Array<Type> => [a];
Đối với trường hợp Generics với arrow function, chúng ta phải sử dụng extends
. Nguyên nhân có thể tham khảo tại đây.
Đôi khi một hàm có thể có nhiều hình thái (tức là tham số truyền vào có thể khác nhau về kiểu hoặc số lượng, kết quả trả về có thể khác kiểu). Người ta gọi đó là overload
.
Cho một ví dụ như sau, chúng ta sẽ hiện thực một hàm calc
, nhận vào 3 tham số: op, a và b. op chỉ có thể là +
hoặc -
. Nếu +
thì a, b phải cùng là string hoặc cùng là number, và type trả về cũng tương ứng. Nhưng nếu op là -
, thì a và b chỉ có thể là number
function calc(op: '+', a: string, b: string): string;function calc(op: '+', a: number, b: number): number;function calc(op: '-', a: number, b: number): number;function calc( op: '+' | '-', a: number | string, b: number | string): number | string { if (op === '-' && typeof a === 'number' && typeof b === 'number') { return a - b; } if (op === '+' && typeof a === 'number' && typeof b === 'number') { return a + b; } if (op === '+' && typeof a === 'string' && typeof b === 'string') { return a + b; } throw new Error('a, b should be the same type');}calc('-', 1, 2);calc('-', '1', '2'); // error
Bạn có thể thử lại đây
Một số ví dụ trên được tham khảo từ kentcdodds blog
let a: { b: string } | { c: string };
Ví dụ trên thể hiện union types. Biến a có kiểu là object và cấu trúc có thể linh hoạt 1 trong 2.
Hoặc là a: { b: string }
hoặc là a: { c: string }
.
Có 3 trường hợp đặc biệt:
let a: string | number | never;// Sẽ trở thànhlet a: string | number;--let b: string | number | any;// Sẽ trở thànhlet b: any;--let c: string | number | unknown;// Sẽ trở thànhlet c: unknown;
Bạn có nhận ra vì sao như vậy không?
Vì any
và unknown
đại diện cho type lớn nhất bao hàm mọi type, và never
thì type nhỏ nhất mà type nào cũng bao hàm.
let a: { b: string } & { c: string };
Intersection types thì khác union, nó gộp các type lại với nhau, nhưng phải đảm bảo việc gộp hợp lý. Như ví dụ trên, ta sẽ có biến a có kiểu là { b: string; c: string }
.
Tuy nhiên, nếu ta khai báo như này:
let a: number & string;
Trong trường hợp này, không có giá trị nào thỏa mãn gộp 2 kiểu đó cả, nên a sẽ có type never
.
Thêm vài ví dụ để dễ hiểu:
let a: { b: string } & { b: number; c: string };let b: { d: { a: string; };} & { d: { c: string; };};
Kết quả sẽ là:
let a: { b: never; c: string };let b: { d: { a: string; c: string; };};
Sẽ nếu ra sao nếu kết hợp union và intersection?
type A = { a: string } | { a: number };type B = { b: string };type C = A & B; // ===> ???
Ta có thể liên tưởng như thế này:
A = A1 + A2;B;C = (A1 + A2) * B;C = A1 * B + A2 * B;
Tất nhiên đó là sự liên tưởng, nhưng khi kết hợp & và | thì cách hoạt động cũng tương tự như vậy, nên ta có
type C = ({ a: string } & { b: string }) | ({ a: number } & { b: string });// ==>type C = { a: string; b: string } | { a: number; b: string };// ==>type C = { a: string | number; b: string;};
Vậy với ví dụ bên dưới thì sao?
type A = { a: string } | { b: number };type B = { c: string } | { d: string };type C = A & B; // ===> ???
Interface với type alias có thể sử dụng thay thế cho nhau trong hầu hết các trường hợp.
Chức năng của chúng dùng để định nghĩa các custom type.
Trong phần này mình chỉ liệt kê sự khác nhau giữa interface và type (Câu này rất được hay hỏi trong phỏng vấn 😁).
intersection
(&)extends
object type
Trong khi interface chỉ định nghĩa các object type, type alias có thể linh động hơn (primitive types, unions, tupples). Ví dụ:
type Request = 'POST' | 'GET';
Trong cùng 1 scope, với 1 tên, type alias chỉ có thể khai báo một lần. Trong khi đó, interface có thể khai báo nhiều lần, và kết quả sẽ là gộp giữa các interface đó với nhau. Điều này thật sự có ích khi chúng ta tạo ra một thư viện, người dùng có thể sử dụng interface để ghi đè hoặc mở rộng type sẵn có.
function f(a: number | string) {}
Trong ví dụ trên, a có thể là number hoặc string, nhưng khi ta xử lý trên a, trước tiên phải xác định kiểu của a. Việc thu hẹp để xác định kiểu của a trong trường hợp này được gọi là narrowing.
Chúng ta có thể sử dụng các từ khóa có sẵn của TS (JS) để thực hiện narrowing, như là: typeof
, instanceof
(được gọi là các type guards).
function f(a: number | string) { if (typeof a === 'number') { // Bây giờ type của a là number // Không còn là string nữa }}
Tuy nhiên, chúng ta có thể tự build type guard của mình bằng cách sử dụng từ khóa is
.
type A = { a: string };type B = { b: string };function f(p: A | B) { // Làm sao để TS console.log(p.a); // error}
Chúng ta phải narrow cho nó trước khi dùng.
type A = { a: string };type B = { b: string };function isA(value: any): value is A { if (typeof value === 'object' && 'a' in value) { return true; } return false;}function f(p: A | B) { if (isA(p)) { console.log(p.a); // it works }}
Xem xét trường hợp dưới đây:
type A = { a?: { num: number; };};function foo(): number { const v: A = { a: { num: 1, }, }; return v.a.num + 1; // error}
Trong ví dụ trên, TS sẽ báo lỗi. Vì v.a
có thể undefined
(theo type A, a đã được khai báo là optional). Nhưng chúng ta biết chắc là v.a có giá trị và không thể nào undefined.
Chúng ta sẽ dùng toán tử !.
để nói với TS rằng mình chắc chắn chuyện đó.
type A = { a?: { num: number; };};function foo(): number { const v: A = { a: { num: 1, }, }; return v.a!.num + 1;}
keyof
: Sử dụng khi muốn lấy type của các keys của một type dạng object. Ví dụ:
type Obj = { 1: string; b: string; c: number;};type Keys = keyof Obj;// Keys = 1 | 'b' | 'c'
Các type keys sau khi sử dụng keyof có thể là number, string, symbol.
Trong ví dụ trên, nếu chúng ta chỉ muốn lấy những keys thuộc kiểu string, thì có thể sử dụng intersection (&):
type Obj = { 1: string; b: string; c: number;};type Keys = keyof Obj;// Keys = 1 | 'b' | 'c'type StringKeys = Keys & string;// Keys = 'b' | 'c'
Tại sao Keys & string
lại chỉ trả về các type dạng string?
type StringKeys = Keys & string;// tương đươngtype StringKeys = (1 | 'b' | 'c') & string;// tương đươngtype StringKeys = (1 & string) | ('b' & string) | ('c' & string);// tương đươngtype StringKeys = never | 'b' | 'c';// tương đươngtype StringKeys = 'b' | 'c';
Trong đoạn code trên, có 2 điều cần chú ý:
"b" & string => "b"
string | never => string
Câu hỏi dành cho bạn:
type A = unknown | string; // ???type A = unknown & string; // ???
typeof
dùng để lấy type của một giá trị.
const a = { b: 1, c: 'Hello world',};type A = typeof a;// Tương đươngtype A = { b: number; c: string;};
en.ts
, vi.ts
,... Vấn đề cần giải quyết đó là làm sao đảm bảo các key trong file en và file vi phải giống nhau, không thể để một bên có và một bên không, gây ra lỗi khi hiển thị.
// file en.tsconst translations = { "Hello": "Hello", "World: "World"}export translations
// file vi.tsconst translations = { "Hello": "Xin chào", "World: "Thế giới"}export translations
Ta có thể nhận thấy giữa 2 file không có ràng buộc gì với nhau.
Đây là lúc chúng ta vận dụng kiến thức về typeof.
// file en.tsconst translations = { Hello: 'Hello', World: 'World',};export type TranslationEn = typeof translations;export default translations;
// file vi.tsimport type { TranslationEn } from './en.ts';const translations: TranslationEn = { Hello: 'Xin chào', World: 'Thế giới',};export default translations;
Viết mãi mới đến phần extends
😅.
Chú ý:
extends
trong Generics khác vớiextends
khi sử dụng để thừa kế với class, interface.
Lấy một ví dụ như thế này. Chúng ta muốn đảm bảo một cái name luôn có last name là T
.
type WithLastName<T> = T extends string ? `${string} ${T}` : never;let name1: WithLastName<'Le Huu'>;name1 = 'Viet Anh Le Huu'; // hợp lệname1 = 'Dao Mai'; // lỗilet name2: WithLastName<123>;name2 = 123; // lỗi
Trong ví dụ này ta có 3 thứ để phân tích
Đầu tiên là với conditional types
, đó chỉ là một thuật ngữ, cách hoạt động của nó giống như toán tử ternary A ? B : C
trong JS.
Đối với vế A trong conditional types đó, chúng ta đang sử dụng extends
. Thường cú pháp sẽ là D extends E
, với D là một type được truyền vào type generics. Nghĩa của nó là D có phải là một type con cụ thể của E hay không (type của D có phải tập con của E hay không).
Ví dụ:
3 extends number
: Trong vô vàn các giá trị (type) của number, thì 3 chính là một type cụ thể hơn của nó.
type User = { name: string;}T extends User{ name: string; age: number } extends User // true
Ví dụ này thì có phần khó hiểu hơn, với type User khi sử dụng với extends, nó sẽ được diễn dịch như thế này: Nhận 1 type T với điều kiện tối thiểu là một object type có ít nhất những thuộc tính như thế này: { name: string }
. Bởi vậy, { name: string; age: number }
sẽ thỏa điều kiện đó.
Xem xét thêm các ví dụ bên dưới (Ví dụ được lấy từ TS course):
1. 64 extends number2. number extends 643. string[] extends any4. string[] extends any[]5. never extends any6. any extends any7. Date extends { new (...args: any[]): any }8. typeof Date extends { new (...args: any[]): any }
Hãy thử trả lời trước khi lướt xem đáp án nào :)
.
.
.
.
.
.
.
.
.
.
Đáp án và giải thích:
64 extends number
là true, 64 là một literal type cụ thể hơn của number.
number extends 64
là false, number không thể là type cụ thể hơn của 64, nếu có 1 type T thỏa mãn T extends 64
thì chỉ có thể là type 64
.
string[] extends any
là true, bất cứ type gì cũng đều xác định và cụ thể type any.
string[] extends any[]
là true, tương tự như trên.
never extends any
là true, never là type cụ thể nhất trong tất cả các type, đại diện cho không có gì :D.
any extends any
là true, any là type vừa khớp với any.
Date extends { new (...args: any[]): any }
là false. type Date thể hiện là type của 1 instance của Date, không phải là type của một constructor function.
typeof Date extends { new (...args: any[]): any }
là true. typeof Date thể hiện type của một constructor function/class.
Và thứ cuối cùng cần chú ý là template literal types.
Trong quá trình làm việc, mình có một bài toán và được rút gọn như dưới đây:
// file generated-types.tstype Product = { name: string;};export type Data = Product[] | null;--// file component.tsimport { Data } from './generated-types.ts';const data: Data;function logProduct(product: any) {}data?.forEach((product) => logProduct(product));
Vấn đề của bài toán trên cần giải quyết đó là typing (khai báo kiểu) cho tham số product của hàm logProduct. Chúng ta bị một ràng buộc là không thể chỉnh sửa file generated-types.ts
, chính vì thế, chúng ta không thể export type Product
ra bên ngoài được. (Không thể chỉnh sửa file generated, bởi vì nó được backend sinh ra, và mỗi lần cập nhật sẽ xóa hết nội dung cũ thay bằng nội dung mới, những gì chỉnh sửa trong file đó sẽ mất.)
Phân tích kỹ hơn, bài toán chúng ta cần làm là tách type Product từ type Data.
Solution đầu tiên, đơn giản nhưng không mang tính scale.
type ProductList = Exclude<Data, null>;type Product = ProductList[number];
Bằng cách này, đầu tiên, sẽ loại bỏ type null trong Data để chỉ còn lại Product[] và gán cho type ProductList. Sau đó, để lấy type của mỗi item trong list, ta sử dụng cú pháp [number]. Như vậy, chúng ta đã lấy được type của Product.
Nhưng tại sao mình lại nhận định đây là một solution không scale. Nếu sau đó phía backend cập nhật type của Data.
type Data = Product[] | string | null;
Phần code chúng ta đã thực hiện Exclude<Data, null>
không còn đúng nữa.
Solution thứ 2 sẽ hiệu quả hơn, chúng ta sẽ xem trước và phân tích sau:
type ExtractElementType<T> = T extends (infer U)[] ? U : never;type Product = ExtractElementType<Data>;
Thoạt đầu nhìn sẽ hơi khó hiểu, nhưng hãy cùng phân tích:
Chúng ta tạo ra một type generics ExtractElementType
nhận vào một type T
. Type T sẽ được xem xét điều kiện để trả ra type cuối cùng. Điều kiện sẽ là T có phải là một type cụ thể, có dạng là U[] hay không, với U sẽ được TS cố gắng infer(suy luận ra). Nếu đáp ứng điều kiện, kết quả sẽ trả về là type U được infer đó, còn không sẽ trả về never.
Tiếp theo, ta sử dụng nó với Data, các bước nó tạo ra kết quả như sau:
type Product = ExtractElementType<Data>;// tương đươngtype Product = ExtractElementType<Product[] | string | null>;// tương đươngtype Product = | ExtractElementType<Product[]> | ExtractElementType<string> | ExtractElementType<null>;// tương đươngtype Product = Product | never | never;// tương đươngtype Product = Product;
Điểm mấu chốt để hiểu ở đây chính là cách hoạt động của union và kết quả union khi kết hợp với never. Đặc biệt, đó là sự tồn tại của từ khóa infer
, TS sẽ cố gắng giúp chúng ta suy luận ra một type nào đó.
Như đã gặp trong các ví dụ trên, chúng ta có thể lấy type của một phần tử, thuộc tính trong một type khác bằng cách sử dụng cú pháp indexed access
. Tính chất này tương tự như truy cập một thuộc tính của object trong JS.
type A = { name: 'Viet Anh' | 'Anh Le';};type B = [number, string, boolean];type Name = A['name']; // "Viet Anh" | "Anh Le"type TuppleItem = B[number]; // number | string | boolean
Chủ yếu của phần này đó là chúng ta có thể lặp qua một type nào đó để tạo ra một type khác.
type A = { [k: string]: string };type B = { [k in 'name' | 'value']: any };type CompoentProps = { [k in keyof Window]: Window[k] };