Generics в TypeScript: главная суперсила типизации

Generics в TypeScript: главная суперсила типизации


Представим, что мы пишем функцию:

function getFirstElement(arr: string[]) {
    return arr[0];
}

Она работает со строками:

getFirstElement(["Vue", "React", "Angular"]);

Но что, если нам нужно получить первый элемент массива чисел?

Можно создать ещё одну функцию:

function getFirstNumber(arr: number[]) {
    return arr[0];
}

А потом ещё для пользователей:

function getFirstUser(arr: User[]) {
    return arr[0];
}

Проблема очевидна: логика абсолютно одинаковая.

Меняется только тип данных.

Именно здесь появляются generics.


Что такое generic?

Generic позволяет написать код, который работает с разными типами, но при этом не теряет информацию о типах.

Например:

function getFirstElement<T>(arr: T[]): T {
    return arr[0];
}

T здесь означает:

«Какой-то тип, который мы узнаем позже».

Теперь функция работает со всем:

const firstString = getFirstElement(["Vue", "React"]);

const firstNumber = getFirstElement([1, 2, 3]);

TypeScript сам понимает:

firstString // string

firstNumber // number

Мы написали одну функцию, но сохранили точную информацию о типах.


Почему нельзя просто использовать any?

Можно написать:

function getFirstElement(arr: any[]): any {
    return arr[0];
}

И вроде бы проблема решена.

Но теперь:

const first = getFirstElement(["Vue", "React"]);

имеет тип:

any

TypeScript больше не знает, что first является строкой.

Можно сделать что угодно:

first.nonExistentMethod();

И TypeScript не будет против.

Generic работает иначе:

function getFirstElement<T>(arr: T[]): T

Он говорит:

«Я не знаю заранее, какой тип ты передашь. Но я запомню его».

Если передали string, вернётся string.

Если number, вернётся number.

Если User, вернётся User.


Generics во frontend

Представим типичный запрос к API.

У нас есть пользователь:

type User = {
    id: number;
    name: string;
};

И функция:

async function fetchUser(): Promise<User> {
    const response = await fetch("/api/user");

    return response.json();
}

Всё хорошо.

Но теперь нам нужны:

/api/users
/api/posts
/api/products
/api/comments

Мы можем написать отдельную функцию для каждого типа данных.

Но логика запроса одинаковая.

Поэтому:

async function request<T>(url: string): Promise<T> {
    const response = await fetch(url);

    return response.json();
}

Теперь:

const user = await request<User>("/api/user");

TypeScript понимает:

user.id;
user.name;

А если мы сделаем:

user.price;

получим ошибку.

Для массива пользователей:

const users = await request<User[]>("/api/users");

Теперь TypeScript знает, что users это массив объектов User.


Generic с несколькими типами

Generics могут принимать несколько параметров.

Например:

function createPair<T, U>(first: T, second: U) {
    return {
        first,
        second
    };
}

Используем:

const pair = createPair("Vue", 3);

TypeScript выводит:

{
    first: string;
    second: number;
}

T и U это просто названия.

Можно написать:

function createPair<Name, Value>(
    first: Name,
    second: Value
) {
    // ...
}

Но обычно используются:

T → Type
K → Key
V → Value

Например:

type Dictionary<K, V> = {
    key: K;
    value: V;
};

Ограничения generics

Иногда нам недостаточно сказать:

«Принимай любой тип».

Представим:

function getLength<T>(value: T) {
    return value.length;
}

TypeScript скажет:

У типа T может не быть свойства length.

И действительно:

getLength("hello");
getLength([1, 2, 3]);

getLength(42);

У числа нет length.

Поэтому можно ограничить generic:

function getLength<T extends { length: number }>(
    value: T
) {
    return value.length;
}

Теперь TypeScript разрешает:

getLength("hello");

getLength([1, 2, 3]);

Но не разрешает:

getLength(42);

То есть:

<T>

означает:

любой тип.

А:

<T extends Something>

означает:

любой тип, который соответствует определённому условию.

Самая важная идея

Generics нужны не для того, чтобы сделать код сложнее.

Наоборот.

Они позволяют избежать такого:

getString(...)
getNumber(...)
getUser(...)
getProduct(...)

И написать одну универсальную функцию:

get<T>(...)

Но при этом не потерять безопасность типов.

Если совсем коротко:

any
↓
«Я ничего не знаю о типе»

generic
↓
«Я пока не знаю тип,
но узнаю его при использовании
и сохраню эту информацию»

Именно поэтому generics встречаются практически везде:

Promise<T>

Array<T>

Record<K, V>

Map<K, V>

Promise<ApiResponse<User>>

ref<User | null>(null)

Как только вы начинаете замечать <T> в TypeScript-коде, оказывается, что половина магии библиотек построена именно на generics.

В следующем посте можно разобрать ещё более практичную тему: как типизировать API во frontend и почему response.json() as User не всегда хорошее решение.



Report Page