Глава 6. Дженерики (обобщённые типы)

126 просмотров
0 лайков
0 в избранном

Зачем нужны дженерики

Дженерик позволяет написать функцию или класс, работающие с разными типами, не теряя информации о типе. Сравним с any:

// Без дженерика — теряем тип
function identityAny(value: any): any {
    return value;
}
const x = identityAny(42);    // тип — any
const y = identityAny("hi");  // тип — any
x.toFixed();   // ОК для TS, но упадёт, если value было числом
y.toUpperCase(); // то же самое

С дженериком тип сохраняется:

function identity<T>(value: T): T {
    return value;
}

const a = identity(42);    // тип — number
const b = identity("hi");  // тип — string

a.toFixed();     // ОК
b.toUpperCase(); // ОК
// a.toUpperCase(); // Ошибка: числа не имеют toUpperCase

Буква T — это просто имя параметра типа. По соглашению используют T (Type), K (Key), V (Value), E (Element), но это не обязательно.

Несколько параметров типа

function pair<A, B>(first: A, second: B): [A, B] {
    return [first, second];
}

const p = pair("Анна", 28); // [string, number]

Явное указание типа

Обычно TypeScript выводит тип по аргументу, но его можно задать явно:

const value = identity<number>(42);
const items = pair<string, boolean>("flag", true);

Дженерики в интерфейсах

Классический пример — обёртка над значением, тип которого известен заранее («коробка»):

interface Box<T> {
    value: T;
}

const numberBox: Box<number> = { value: 42 };
const stringBox: Box<string> = { value: "hi" };

// numberBox.value.toFixed(); // ОК
// numberBox.value.toUpperCase(); // Ошибка

Кейс: типобезопасный API-ответ

interface ApiResponse<T> {
    status: "ok" | "error";
    data: T;
    meta?: { page: number; total: number };
}

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

const response: ApiResponse<User> = {
    status: "ok",
    data: { id: 1, name: "Анна" },
};

// TS знает, что response.data — это User
console.log(response.data.name);

Кейс: коллекция с указанным типом элементов

interface PaginatedList<T> {
    items: T[];
    total: number;
    page: number;
}

const usersPage: PaginatedList<User> = {
    items: [{ id: 1, name: "Анна" }],
    total: 1,
    page: 1,
};

Ограничения (constraints)

Ключевое слово extends ограничивает множество допустимых типов. Например, требуем, чтобы у значения было свойство length:

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

getLength("hello");     // 5 — у строки есть length
getLength([1, 2, 3]);   // 3 — у массива есть length
// getLength(42);        // Ошибка: у числа нет length

keyof — тип «ключей объекта»

Часто нужно требовать, чтобы ключ существовал в типе. keyof T даёт объединение всех ключей типа T:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user = { name: "Анна", age: 28 };

getProperty(user, "name"); // string — TS знает тип значения
getProperty(user, "age");  // number
// getProperty(user, "email"); // Ошибка: такого ключа нет

T[K] — это индексированный доступ к типу: «тип значения по ключу K в объекте T».

Кейс: типобезопасный setter

function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
    obj[key] = value;
}

const config = { port: 3000, host: "localhost" };

setProperty(config, "port", 8080);   // OK
// setProperty(config, "port", "8080"); // Ошибка: port — number

Дженерики в классах

class Stack<T> {
    private items: T[] = [];

    push(item: T): void {
        this.items.push(item);
    }

    pop(): T | undefined {
        return this.items.pop();
    }

    peek(): T | undefined {
        return this.items[this.items.length - 1];
    }

    get size(): number {
        return this.items.length;
    }
}

const numbers = new Stack<number>();
numbers.push(1);
numbers.push(2);
const n = numbers.pop(); // number | undefined

const strings = new Stack<string>();
strings.push("hi");
// strings.push(42); // Ошибка: ожидается string

Кейс: кэш с ключами любого типа

class Cache<K, V> {
    private store = new Map<K, V>();

    set(key: K, value: V): void {
        this.store.set(key, value);
    }

    get(key: K): V | undefined {
        return this.store.get(key);
    }

    has(key: K): boolean {
        return this.store.has(key);
    }
}

// Ключи — числа, значения — строки
const userCache = new Cache<number, string>();
userCache.set(1, "Анна");
userCache.set(2, "Борис");

// Ключи — строки, значения — объекты
const apiCache = new Cache<string, { status: number }>();
apiCache.set("/users", { status: 200 });

Параметры типа по умолчанию

У параметра типа может быть значение по умолчанию:

interface ApiResponse<T, E = Error> {
    data?: T;
    error?: E;
}

// E по умолчанию = Error
const r1: ApiResponse<User> = { data: { id: 1, name: "x" } };

// E можно переопределить
const r2: ApiResponse<User, string> = { error: "не найдено" };

typeof — тип по значению

Иногда нужно получить тип из конкретного значения:

const config = {
    apiUrl: "https://api.example.com",
    timeout: 5000,
    retries: 3,
};

type Config = typeof config;
// { apiUrl: string; timeout: number; retries: number }

function init(c: Config): void {
    // ...
}

init(config);

Кейс: константы и literal-типы через as const

const STATUSES = ["new", "paid", "done"] as const;
type Status = typeof STATUSES[number]; // "new" | "paid" | "done"

function setStatus(s: Status): void {
    // ...
}

setStatus("new");
// setStatus("closed"); // Ошибка

Кейс: типобезопасный event emitter

Реальный пример — события с типизированными именами и payload. Сначала описываем карту событий, потом дженерик гарантирует соответствие имени и аргументов:

type EventMap = {
    login: { userId: number };
    logout: undefined;
    purchase: { orderId: number; amount: number };
};

class TypedEmitter<Events extends Record<string, unknown>> {
    private handlers: { [K in keyof Events]?: ((payload: Events[K]) => void)[] } = {};

    on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
        (this.handlers[event] ??= []).push(handler);
    }

    emit<K extends keyof Events>(event: K, payload: Events[K]): void {
        this.handlers[event]?.forEach((h) => h(payload));
    }
}

const emitter = new TypedEmitter<EventMap>();

emitter.on("login", (p) => {
    console.log(p.userId); // TS знает про userId
});

emitter.emit("login", { userId: 1 });          // OK
// emitter.emit("login", { orderId: 1 });         // Ошибка
// emitter.emit("signup", { userId: 1 });          // Ошибка: нет такого события

Типичные ошибки

1. Использование any там, где нужен дженерик

// Плохо: теряем тип
function first(arr: any[]): any {
    return arr[0];
}

// Хорошо: тип сохраняется
function first<T>(arr: T[]): T | undefined {
    return arr[0];
}

2. Слишком жёсткий constraint

// Избыточно: требует именно { name: string }, хотя достаточно length
function getLen<T extends { name: string }>(v: T): number {
    return v.name.length;
}

3. Путаница между параметром типа и обычным параметром

// <T> — параметр типа, не передаётся в рантайме
function f<T>(value: T): T { return value; }

Практика

  1. Напишите дженерик-функцию first<T>(arr: T[]): T | undefined.
  2. Реализуйте функцию groupBy<T>(arr: T[], key: (item: T) => string): Record<string, T[]>.
  3. Создайте класс Dictionary<V> с методами set/get/has.
  4. Опишите функцию pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> (или используйте готовый utility-тип).
  5. Реализуйте простую типобезопасную очередь Queue<T> с enqueue/dequeue.

Итог

Дженерики — главный инструмент для переиспользуемого типобезопасного кода. Мы разобрали синтаксис функций, интерфейсов и классов, ограничения через extends, keyof и индексированный доступ, параметры типа по умолчанию, typeof и реальные паттерны: API-ответы, кэш и типизированный event emitter. В следующей главе — модули и конфигурация проекта.

Комментарии 0

Для добавления комментариев необходимо войти или зарегистрироваться.

Пока нет комментариев. Станьте первым!