Пошук уроків, статей та іншого контенту
Параметризуйте класи типами, щоб повторно використовувати їх із різними даними без втрати типобезпеки.
Узагальнений, або generic-клас, — це клас, який працює з типом, переданим під час створення екземпляра.
Замість того щоб створювати окремі класи для рядків, чисел або об’єктів, можна описати один клас:
class Box<T> {
constructor(public value: T) {}
getValue(): T {
return this.value;
}
}T — параметр типу. Він не є конкретним типом, а позначає тип, який буде визначено пізніше.
Під час використання класу можна вказати конкретний тип:
const numberBox = new Box<number>(42);
const textBox = new Box<string>("TypeScript");
const number = numberBox.getValue(); // number
const text = textBox.getValue(); // stringTypeScript також часто може вивести параметр типу з аргументу конструктора:
const booleanBox = new Box(true);
// TypeScript визначає T як booleanGeneric-класи дають змогу:
повторно використовувати одну реалізацію для різних типів;
зберігати інформацію про типи;
отримувати підказки редактора;
виявляти помилки під час компіляції;
уникати небезпечних приведень типів.
Порівняймо generic-клас із класом, який зберігає значення як unknown:
class SafeBox<T> {
constructor(private value: T) {}
getValue(): T {
return this.value;
}
}
const userBox = new SafeBox({ name: "Олена", age: 28 });
const user = userBox.getValue();
console.log(user.name); // string
console.log(user.age); // numberТип об’єкта зберігається протягом усієї роботи з SafeBox. Не потрібно вручну перевіряти або приводити тип результату.
Параметр типу класу доступний у всіх властивостях і методах цього класу:
class Storage<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
get(index: number): T | undefined {
return this.items[index];
}
getAll(): T[] {
return [...this.items];
}
}
const names = new Storage<string>();
names.add("Олена");
names.add("Андрій");
const firstName = names.get(0); // string | undefined
console.log(firstName);Оскільки метод get може звернутися до індексу, якого немає, його результат має тип T | undefined.
TypeScript не дозволить додати значення іншого типу:
const numbers = new Storage<number>();
numbers.add(10);
// numbers.add("20"); // Помилка: string не можна передати замість numberКлас може мати кілька параметрів типу. Наприклад, клас може зберігати ключ і відповідне йому значення:
class KeyValueStore<K, V> {
private entries: Array<{ key: K; value: V }> = [];
set(key: K, value: V): void {
this.entries.push({ key, value });
}
get(key: K): V | undefined {
const entry = this.entries.find((item) => item.key === key);
return entry?.value;
}
}
const userNames = new KeyValueStore<number, string>();
userNames.set(1, "Олена");
userNames.set(2, "Андрій");
const name = userNames.get(1); // string | undefined
console.log(name);Порядок параметрів типу має значення:
const store = new KeyValueStore<number, string>();Тут:
K — number;
V — string.
Без обмежень параметр типу може бути майже будь-яким типом. Наприклад:
class Printer<T> {
print(value: T): void {
console.log(value);
}
}Усередині цього класу не можна припускати, що T має певні властивості:
class Repository<T> {
// Не можна безпечно звернутися до item.id,
// адже T може бути рядком, числом або іншим типом.
}Generic constraint обмежує перелік типів, які можна передати як параметр. Для цього використовується ключове слово extends.
type Entity = {
id: number;
};
class Repository<T extends Entity> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: number): T | undefined {
return this.items.find((item) => item.id === id);
}
}Запис T extends Entity означає:
Тип
Tповинен мати щонайменше всі властивості типуEntity.
Завдяки цьому всередині класу безпечно використовувати item.id.
Розглянемо повний приклад із користувачами та товарами:
type Entity = {
id: number;
};
type User = Entity & {
name: string;
email: string;
};
type Product = Entity & {
title: string;
price: number;
};
class Repository<T extends Entity> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: number): T | undefined {
return this.items.find((item) => item.id === id);
}
getAll(): T[] {
return [...this.items];
}
removeById(id: number): boolean {
const index = this.items.findIndex((item) => item.id === id);
if (index === -1) {
return false;
}
this.items.splice(index, 1);
return true;
}
}
const users = new Repository<User>();
users.add({
id: 1,
name: "Олена",
email: "olena@example.com",
});
users.add({
id: 2,
name: "Андрій",
email: "andrii@example.com",
});
const user = users.findById(1);
if (user) {
console.log(user.name);
console.log(user.email);
}
const products = new Repository<Product>();
products.add({
id: 101,
title: "Клавіатура",
price: 2500,
});
products.add({
id: 102,
title: "Миша",
price: 1200,
});
const product = products.findById(101);
if (product) {
console.log(product.title);
console.log(product.price);
}Один і той самий Repository<T> працює і з User, і з Product, але кожен екземпляр зберігає лише свій тип даних.
Наприклад:
const users = new Repository<User>();
// users.add({
// id: 3,
// title: "Це не користувач",
// price: 100,
// }); // Помилка: об'єкт не відповідає типу UserОбмеження можна будувати не лише на власних типах. Наприклад, можна дозволити тільки типи, які мають властивість length:
class LengthReporter<T extends { length: number }> {
constructor(private value: T) {}
report(): string {
return `Довжина: ${this.value.length}`;
}
}
const textReporter = new LengthReporter("TypeScript");
const arrayReporter = new LengthReporter([1, 2, 3]);
console.log(textReporter.report());
console.log(arrayReporter.report());І string, і масив мають властивість length, тому вони відповідають constraint.
Число не має length, тому такий код буде помилковим:
// const numberReporter = new LengthReporter(42);Constraint не обов’язково має бути окремим іменованим типом. Його можна записати безпосередньо:
class IdReader<T extends { id: number }> {
constructor(private item: T) {}
getId(): number {
return this.item.id;
}
}keyofІноді потрібно дозволити лише ключі, які справді існують у переданому типі. Для цього використовують keyof.
class PropertyReader<T> {
constructor(private value: T) {}
get<K extends keyof T>(key: K): T[K] {
return this.value[key];
}
}
const userReader = new PropertyReader({
id: 1,
name: "Олена",
active: true,
});
const id = userReader.get("id"); // number
const name = userReader.get("name"); // string
const active = userReader.get("active"); // boolean
console.log(id, name, active);У цьому прикладі:
keyof T — об’єднання допустимих ключів типу T;
K extends keyof T — параметр K може бути лише ключем T;
T[K] — тип значення за ключем K.
TypeScript не дозволить передати ключ, якого немає в об’єкті:
// userReader.get("email"); // Помилка: email не є ключем цього об'єктаЦе особливо корисно для універсальних класів, які читають або змінюють властивості об’єктів.
Generic-клас можна розширювати через extends.
class Collection<T> {
protected items: T[] = [];
add(item: T): void {
this.items.push(item);
}
getAll(): T[] {
return [...this.items];
}
}
class NumberCollection extends Collection<number> {
sum(): number {
return this.items.reduce((total, item) => total + item, 0);
}
}
const numbers = new NumberCollection();
numbers.add(10);
numbers.add(20);
numbers.add(30);
console.log(numbers.getAll());
console.log(numbers.sum());NumberCollection успадковує Collection<number>, тому його items має тип number[].
Можна також залишити клас узагальненим:
class SortedCollection<T> extends Collection<T> {
constructor(private compare: (a: T, b: T) => number) {
super();
}
getSorted(): T[] {
return [...this.items].sort(this.compare);
}
}
const scores = new SortedCollection<number>((a, b) => a - b);
scores.add(30);
scores.add(10);
scores.add(20);
console.log(scores.getSorted());Якщо створити два екземпляри одного generic-класу з різними типами, вони будуть незалежними з погляду типізації:
const textStorage = new Storage<string>();
const numberStorage = new Storage<number>();
textStorage.add("hello");
numberStorage.add(100);Тип string належить лише textStorage, а тип number — лише numberStorage.
Параметри типу існують для перевірки TypeScript і не зберігаються як значення JavaScript під час виконання.
Тому не можна написати:
class Example<T> {
// Не можна перевірити тип T через typeof T
}Якщо класу потрібно виконувати різну логіку залежно від типу, зазвичай передають додаткову функцію або інший runtime-механізм. Сам generic-параметр для цього не підходить.
Запис:
class Repository<T extends Entity> {
// ...
}не означає, що T дорівнює саме Entity. Тип T може мати додаткові властивості:
type Admin = Entity & {
name: string;
permissions: string[];
};
const admins = new Repository<Admin>();Усередині Repository гарантовано доступна властивість id, а код, який використовує Repository<Admin>, також знає про name і permissions.
Якщо клас насправді працює з об’єктами, які мають id, не варто використовувати неконтрольований T:
// class Repository<T> {
// findById(id: number): T | undefined {
// return this.items.find((item) => item.id === id);
// }
// }У такому варіанті TypeScript не може гарантувати наявність id.
Правильніше додати constraint:
class Repository<T extends { id: number }> {
private items: T[] = [];
findById(id: number): T | undefined {
return this.items.find((item) => item.id === id);
}
}Constraint має описувати реальну вимогу класу, але не більше. Якщо класу потрібна лише властивість id, не потрібно вимагати цілу велику структуру:
type VeryLargeEntity = {
id: number;
name: string;
createdAt: Date;
updatedAt: Date;
};Якщо в реалізації використовується тільки id, достатньо:
type Identifiable = {
id: number;
};Так клас залишатиметься придатним для більшої кількості типів.
T і конкретним типомУ generic-класі не потрібно замінювати T на конкретний тип:
class Box<T> {
constructor(private value: T) {}
getValue(): T {
return this.value;
}
}Якщо замість T використати string, клас втратить універсальність:
class StringBox {
constructor(private value: string) {}
getValue(): string {
return this.value;
}
}StringBox може працювати лише з рядками, а Box<T> — із будь-яким потрібним типом.
Generic-клас оголошується через параметр типу після імені класу: class Box<T>.
Параметр T можна використовувати у властивостях, конструкторах і методах класу.
Один generic-клас можна повторно використовувати для різних типів.
Кілька параметрів записуються через кому: class Store<K, V>.
Constraint задається через extends і визначає мінімальні вимоги до типу.
T extends Entity дозволяє використовувати гарантовані властивості Entity.
keyof допомагає обмежувати параметр ключами іншого типу.
Generic забезпечує типобезпеку під час компіляції, але не існує як значення під час виконання.
Хороший constraint має бути достатньо точним для реалізації, але не обмежувати клас без потреби.