Trong bài này, mình sẽ chia sẻ với anh em cách tạo kiểu từ kiểu (types from types) trong TypeScript. Đối với những dự án lớn, việc quản lý kiểu dữ liệu là cực kỳ quan trọng để đảm bảo code dễ maintain, dễ scale và hạn chế lỗi. TypeScript cung cấp rất nhiều công cụ mạnh mẽ giúp anh em làm điều đó như Generics, keyof, typeof, Conditional Types, Mapped Types và Template Literal Types.

Hôm nay, mình sẽ đi sâu vào từng kỹ thuật, với những ví dụ từ đơn giản đến phức tạp hơn để anh em có thể áp dụng ngay vào project của mình.


1. Generics – Làm Việc Với Kiểu Động


Generics là một trong những điểm mạnh của TypeScript, giúp anh em có thể tạo ra những hàm, class hoặc interface linh hoạt mà không cần biết trước chính xác kiểu dữ liệu sẽ làm việc với nó. Điều này cực kỳ hữu ích khi làm việc với các cấu trúc dữ liệu hoặc các hàm tái sử dụng nhiều lần.


interface ApiResponse<T> {
    status: number;
    data: T;
    error?: string;
}

function fetchData<T>(url: string): Promise<ApiResponse<T>> {
    return fetch(url)
        .then(response => response.json())
        .then(data => ({ status: 200, data }))
        .catch(error => ({ status: 500, error: error.message }));
}

interface User {
    id: number;
    name: string;
}

fetchData<User>("/api/users").then(response => {
    if (response.status === 200) {
        console.log(response.data.name); // type-safe
    }
});


Ở đây mình sử dụng Generics để xây dựng một hàm fetchData có thể trả về phản hồi (response) với kiểu dữ liệu khác nhau, tùy thuộc vào API mà anh em gọi. Điều này đảm bảo rằng dữ liệu trả về được type-safe và dễ kiểm soát hơn.


2. keyof – Lấy Tập Hợp Các Thuộc Tính Từ Kiểu


Toán tử keyof giúp anh em lấy toàn bộ key của một object và chuyển nó thành một union type. Đây là cách cực kỳ hay để tạo ra các kiểu động dựa trên cấu trúc object hiện có.


interface Form {
    name: string;
    age: number;
    email: string;
}

type FormKeys = keyof Form// 'name' | 'age' | 'email'

function updateForm<T extends keyof Form>(key: T, value: Form[T]): void {
    console.log(`Updating ${key} with value ${value}`);
}

updateForm('name', 'Alice'); // type-safe
updateForm('age', 25);       // type-safe


Trong ví dụ này, keyof giúp chúng ta tạo ra kiểu FormKeys chứa toàn bộ các key của interface Form. Hàm updateForm đảm bảo rằng anh em chỉ có thể cập nhật các thuộc tính hợp lệ với kiểu dữ liệu đúng.


3. typeof – Tạo Kiểu Dựa Trên Giá Trị Có Sẵn


Toán tử typeof không chỉ giúp kiểm tra kiểu của một giá trị trong runtime mà còn có thể dùng để tạo kiểu dựa trên một biến cụ thể trong quá trình compile-time.


const user = {
    id: 1,
    name: "Bob",
    age: 25
};

type UserType = typeof user;

function printUserInfo(userInfo: UserType): void {
    console.log(`${userInfo.name}, Age: ${userInfo.age}`);
}

printUserInfo(user); // OK


Ở đây, UserType tự động được tạo từ đối tượng user, nhờ đó anh em có thể tránh việc lặp lại khai báo kiểu thủ công. Điều này giúp code gọn gàng hơn và dễ bảo trì hơn.


4. Indexed Access Types – Truy Cập Kiểu Con Bằng Cú Pháp Type['Property']


Indexed Access Types cho phép anh em truy cập trực tiếp kiểu của một thuộc tính trong một object hoặc interface, và rất hữu ích khi làm việc với các cấu trúc dữ liệu phức tạp.


interface ApiResponse<T> {
    status: number;
    data: T;
}

interface User {
    id: number;
    name: string;
    address: {
        street: string;
        city: string;
    };
}

type AddressType = User['address'];  // { street: string; city: string }
type CityType = User['address']['city'];  // string

const userAddress: AddressType = {
    street: "123 Main St",
    city: "Wonderland"
};

console.log(userAddress.city);  // "Wonderland"


Ví dụ này thể hiện cách truy cập vào các kiểu dữ liệu con bên trong kiểu User. Indexed Access Types giúp chúng ta dễ dàng làm việc với các kiểu lồng nhau mà vẫn đảm bảo tính chính xác về kiểu dữ liệu.


5. Conditional Types – Kiểu Điều Kiện Giống Câu Lệnh if


Conditional Types giúp anh em tạo ra các kiểu có điều kiện, dựa trên giá trị của một kiểu khác. Điều này cực kỳ hữu ích khi cần xử lý những trường hợp mà kiểu dữ liệu có thể thay đổi dựa trên ngữ cảnh.


type IsArray<T> = T extends any[] ? "array" : "not array";

type Test1 = IsArray<string[]>;  // "array"
type Test2 = IsArray<number>;    // "not array"


Conditional Types giống như các câu lệnh if trong hệ thống kiểu của TypeScript, giúp chúng ta kiểm tra và trả về kiểu tương ứng dựa trên điều kiện.


Conditional types có thể trở nên phức tạp hơn khi chúng ta sử dụng nhiều điều kiện để kiểm tra kiểu và trả về kiểu tương ứng.


Ví dụ:

type Flatten<T> = T extends Array<infer U> ? U : T;
type DeepFlatten<T> = T extends Array<infer U> ? DeepFlatten<U> : T;

type Test1 = Flatten<number[]>; // number
type Test2 = Flatten<number[][][]>; // number[][]
type Test3 = DeepFlatten<number[][][]>; // number


Trong ví dụ này, Flatten chỉ xử lý mảng một cấp, trong khi DeepFlatten đệ quy để làm phẳng mảng nhiều cấp, tạo ra một kiểu phức tạp hơn.


6. Mapped Types – Tạo Kiểu Mới Từ Các Kiểu Hiện Có


Mapped Types cho phép anh em tạo ra một kiểu mới bằng cách ánh xạ qua tất cả các thuộc tính của một kiểu hiện có. Kỹ thuật này giúp giảm thiểu sự lặp lại khi thao tác trên các kiểu dữ liệu lớn, và giúp linh hoạt hóa việc thay đổi các thuộc tính.


type OptionsFlags<Type> = {
    [Property in keyof Type]: boolean;
};

interface Features {
    darkMode: () => void;
    newUserProfile: () => void;
}

type FeatureOptions = OptionsFlags<Features>;


Ở đây, OptionsFlags ánh xạ tất cả các thuộc tính của Features thành kiểu boolean. Điều này giúp tạo ra kiểu mới FeatureOptions với tất cả các thuộc tính là boolean.


Modifiers trong Mapped Types


Anh em có thể sử dụng các modifier như readonly? trong Mapped Types để kiểm soát tính bất biến (immutability) và tính tùy chọn (optionality) của các thuộc tính.


Ví dụ thêm/xóa readonly:

type Mutable<Type> = {
    -readonly [Property in keyof Type]: Type[Property];
};

interface LockedAccount {
    readonly id: string;
    readonly name: string;
}

type UnlockedAccount = Mutable<LockedAccount>;


Ở đây, Mutable loại bỏ thuộc tính readonly khỏi tất cả các thuộc tính của LockedAccount, cho phép anh em sửa đổi giá trị sau khi khởi tạo.


Ví dụ thêm/xóa optional:

type RequiredFields<Type> = {
    [Property in keyof Type]-?: Type[Property];
};

interface UserProfile {
    id: string;
    name?: string;
    age?: number;
}

type CompleteProfile = RequiredFields<UserProfile>;


RequiredFields loại bỏ dấu ? trên tất cả các thuộc tính, đảm bảo rằng tất cả các thuộc tính trong UserProfile đều bắt buộc.


Remapping Keys – Đổi Tên Thuộc Tính


Từ TypeScript 4.1 trở đi, anh em có thể remap các key trong Mapped Types bằng cách sử dụng từ khóa as. Điều này cho phép anh em tạo ra các tên thuộc tính mới hoặc loại bỏ các thuộc tính không mong muốn.


Ví dụ remap key với Template Literal Types:

type Getters<Type> = {
    [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property];
};

interface Person {
    name: string;
    age: number;
    location: string;
}

type LazyPerson = Getters<Person>;


Ở đây, mình sử dụng as để tạo ra các hàm getter dựa trên các thuộc tính của Person. Kết quả là kiểu LazyPerson chứa các hàm như getName, getAge, và getLocation.


Loại bỏ thuộc tính bằng conditional type:


type RemoveKind<Type> = {
    [Property in keyof Type as Exclude<Property, "kind">]: Type[Property];
};

interface Circle {
    kind: "circle";
    radius: number;
}

type CircleWithoutKind = RemoveKind<Circle>;


Trong ví dụ này, mình loại bỏ thuộc tính kind khỏi kiểu Circle, giúp tạo ra một kiểu mới chỉ chứa radius.


Mapped Types Với Union


type EventConfig<Events extends { kind: string }> = {
    [E in Events as E["kind"]]: (event: E) => void;
};

type SquareEvent = { kind: "square"; x: number; y: number };
type CircleEvent = { kind: "circle"; radius: number };

type Config = EventConfig<SquareEvent | CircleEvent>;


Ở đây, EventConfig ánh xạ qua tất cả các kiểu sự kiện (event) và tạo ra một cấu hình sự kiện cho mỗi loại sự kiện.


7. Template Literal Types – Kết Hợp Mapped Types và Chuỗi Template


Template Literal Types cho phép anh em tạo ra các kiểu mới bằng cách kết hợp chuỗi và kiểu dữ liệu. Khi kết hợp với Mapped Types, chúng ta có thể tạo ra những kiểu cực kỳ linh hoạt.


type EventName<T extends string> = `${T}Event`;

type ComponentEvents = {
    [K in "click" | "hover" | "focus"]: `${K}Handler`;
};

// Result: { clickHandler: string, hoverHandler: string, focusHandler: string }


Template Literal Types là cách rất hay để xây dựng các kiểu động liên quan đến chuỗi mà không cần khai báo thủ công từng loại.


Kết Luận


Hy vọng bài viết này đã giúp anh em hiểu rõ hơn về cách tận dụng hệ thống kiểu mạnh mẽ của TypeScript. Việc tạo kiểu từ kiểu cho phép chúng ta xây dựng các hệ thống dữ liệu phức tạp mà vẫn đảm bảo tính chặt chẽ và dễ maintain. Các công cụ như Generics, keyof, typeof, Indexed Access Types, Conditional Types, Mapped Types và Template Literal Types sẽ giúp anh em tối ưu hóa codebase, giữ cho code an toàn và hiệu quả hơn trong các dự án lớn.


Trong bài viết này mình có tham khảo document từ Typescript phần Type Manipulation: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html anh em có thể đọc ở đây để hiểu rõ hơn nhé.


Nếu anh em có câu hỏi hoặc ví dụ nào khác, hãy để lại bình luận, mình sẽ cùng thảo luận nhé!