Пошук уроків, статей та іншого контенту
Проєктуємо типобезпечний контейнер даних із Generic Functions, constraints, keyof та кількома параметрами типу.
Контейнер даних зберігає колекцію об’єктів і надає операції для:
додавання елементів;
пошуку за будь-яким ключем;
отримання елемента за ідентифікатором;
перетворення елементів у значення іншого типу;
групування елементів за властивістю.
Проблема нетипізованого контейнера полягає в тому, що помилки з’являються лише під час виконання:
container.findBy("rol", "admin"); // Помилка в назві ключа виявиться пізно
container.findBy("age", "admin"); // Значення несумісне з типом ageGenerics дають змогу описати ці обмеження на рівні компіляції.
Почнемо з контейнера для сутностей, які мають ідентифікатор:
class DataContainer<T extends { readonly id: PropertyKey }> {
private readonly items: T[];
constructor(initialItems: readonly T[] = []) {
this.items = [...initialItems];
}
add(item: T): void {
this.items.push(item);
}
all(): readonly T[] {
return [...this.items];
}
getById(id: T["id"]): T | undefined {
return this.items.find((item) => item.id === id);
}
findBy<K extends keyof T>(key: K, expectedValue: T[K]): T[] {
return this.items.filter((item) => item[key] === expectedValue);
}
map<U>(transform: (item: T, index: number) => U): U[] {
return this.items.map(transform);
}
}T extends ...Оголошення:
T extends { readonly id: PropertyKey }означає, що T може бути будь-яким типом, але він обов’язково повинен містити властивість id.
PropertyKey — це вбудований тип TypeScript, який охоплює допустимі ключі об’єктів:
type PropertyKey = string | number | symbol;Завдяки обмеженню контейнер безпечно реалізує метод getById:
getById(id: T["id"]): T | undefinedТип T["id"] є типом властивості id у конкретному типі T.
Наприклад, якщо T — це User, де id має тип number, то параметр id у getById автоматично матиме тип number.
keyof та indexed access typesУ методі пошуку використано два важливих оператори:
findBy<K extends keyof T>(key: K, expectedValue: T[K]): T[]keyof Tkeyof T створює об’єднання всіх ключів типу T.
interface User {
id: number;
name: string;
role: "admin" | "user";
}
type UserKey = keyof User;
// "id" | "name" | "role"Тому параметр key може містити лише справжній ключ об’єкта User.
T[K]T[K] — це тип значення властивості, ключ якої зберігається в K.
type NameType = User["name"];
// string
type RoleType = User["role"];
// "admin" | "user"У методі findBy TypeScript пов’язує ключ і значення:
container.findBy("name", "Олена"); // коректно
container.findBy("role", "admin"); // коректно
container.findBy("id", 10); // коректно
container.findBy("role", 10); // помилка типізації
container.findBy("unknown", "x"); // помилка типізаціїЦе важливіше, ніж просто оголосити обидва параметри як string або unknown: тип значення залежить від переданого ключа.
Метод findBy має два параметри типу:
<K extends keyof T>Тут T належить класу, а K визначається окремо для кожного виклику методу.
У функції групування також використано два параметри типу:
function groupBy<T extends object, K extends keyof T>(
items: readonly T[],
key: K
): Map<T[K], T[]> {
const result = new Map<T[K], T[]>();
for (const item of items) {
const value = item[key];
const group = result.get(value);
if (group) {
group.push(item);
} else {
result.set(value, [item]);
}
}
return result;
}Тут:
T — тип елемента колекції;
K — ключ, за яким виконується групування;
K extends keyof T гарантує, що ключ існує в T;
T[K] стає типом ключа у Map.
Наприклад, якщо групувати користувачів за role, результат матиме тип:
Map<"admin" | "user", User[]>Нижче наведено самодостатній приклад, який можна скомпілювати зі строгими перевірками TypeScript.
interface User {
readonly id: number;
name: string;
role: "admin" | "user";
active: boolean;
}
class DataContainer<T extends { readonly id: PropertyKey }> {
private readonly items: T[];
constructor(initialItems: readonly T[] = []) {
this.items = [...initialItems];
}
add(item: T): void {
this.items.push(item);
}
all(): readonly T[] {
return [...this.items];
}
getById(id: T["id"]): T | undefined {
return this.items.find((item) => item.id === id);
}
findBy<K extends keyof T>(key: K, expectedValue: T[K]): T[] {
return this.items.filter((item) => item[key] === expectedValue);
}
map<U>(transform: (item: T, index: number) => U): U[] {
return this.items.map(transform);
}
}
function groupBy<T extends object, K extends keyof T>(
items: readonly T[],
key: K
): Map<T[K], T[]> {
const result = new Map<T[K], T[]>();
for (const item of items) {
const value = item[key];
const group = result.get(value);
if (group) {
group.push(item);
} else {
result.set(value, [item]);
}
}
return result;
}
function pluck<T extends object, K extends keyof T>(
items: readonly T[],
key: K
): T[K][] {
return items.map((item) => item[key]);
}
const users = new DataContainer<User>([
{
id: 1,
name: "Олена",
role: "admin",
active: true,
},
{
id: 2,
name: "Максим",
role: "user",
active: true,
},
{
id: 3,
name: "Ірина",
role: "admin",
active: false,
},
]);
users.add({
id: 4,
name: "Андрій",
role: "user",
active: true,
});
const user = users.getById(2);
console.log("Користувач із id 2:", user);
const administrators = users.findBy("role", "admin");
console.log("Адміністратори:", administrators);
const activeUsers = users.findBy("active", true);
console.log("Активні користувачі:", activeUsers);
const names = users.map((currentUser) => currentUser.name);
console.log("Імена:", names);
const userSummaries = users.map((currentUser) => ({
label: `${currentUser.name} (${currentUser.role})`,
isAvailable: currentUser.active,
}));
console.log("Скорочені дані:", userSummaries);
const usersByRole = groupBy(users.all(), "role");
console.log("Користувачі за ролями:", usersByRole);
const ids = pluck(users.all(), "id");
const roles = pluck(users.all(), "role");
console.log("Ідентифікатори:", ids);
console.log("Ролі:", roles);У цьому виклику:
const administrators = users.findBy("role", "admin");TypeScript виводить:
K = "role"
T[K] = User["role"]Оскільки User["role"] дорівнює "admin" | "user", другий аргумент повинен мати саме цей тип.
У виклику:
const activeUsers = users.findBy("active", true);виводиться:
K = "active"
T[K] = User["active"]А User["active"] — це boolean.
Результат методу в обох випадках — User[], але тип другого аргументу залежить від ключа.
pluckФункція pluck повертає значення конкретної властивості для всіх елементів:
function pluck<T extends object, K extends keyof T>(
items: readonly T[],
key: K
): T[K][] {
return items.map((item) => item[key]);
}Результат також типобезпечний:
const names = pluck(users.all(), "name");
// string[]
const ids = pluck(users.all(), "id");
// number[]
const roles = pluck(users.all(), "role");
// ("admin" | "user")[]Функції не потрібно вручну передавати параметри типу. TypeScript виводить їх із аргументів.
Параметри типу можна передати явно:
const grouped = groupBy<User, "role">(users.all(), "role");Зазвичай це не потрібно, оскільки компілятор може виконати виведення типів:
const grouped = groupBy(users.all(), "role");Явне зазначення може бути корисним, коли:
типи потрібно зафіксувати ширше або вужче;
аргумент має недостатньо конкретний тип;
складний виклик важко перевірити лише за виведенням типів.
Такі виклики є помилковими:
const container = new DataContainer<User>();
// Помилка: "unknown" не є ключем User
container.findBy("unknown", "value");
// Помилка: role може мати лише значення "admin" або "user"
container.findBy("role", "moderator");
// Помилка: active має тип boolean
container.findBy("active", "yes");
// Помилка: id має тип number
container.getById("2");Ці обмеження не залежать від реалізації методу. Вони формуються типами ще до запуску програми.
string замість keyof TНебезпечний варіант:
function getValue<T extends object>(
item: T,
key: string
): unknown {
return item[key as keyof T];
}У цьому випадку будь-який рядок приймається як ключ, а результат втрачає конкретний тип.
Безпечний варіант:
function getValue<T extends object, K extends keyof T>(
item: T,
key: K
): T[K] {
return item[key];
}Тепер TypeScript перевіряє і ключ, і тип результату.
Небезпечний сигнатурний варіант:
function findBy<T>(
items: T[],
key: keyof T,
value: unknown
): T[] {
return items.filter((item) => item[key] === value);
}Такий код дозволяє передати будь-яке значення. Ключ і значення не пов’язані між собою.
Краще використовувати:
function findBy<T extends object, K extends keyof T>(
items: readonly T[],
key: K,
value: T[K]
): T[] {
return items.filter((item) => item[key] === value);
}Обмеження:
T extends objectгарантує лише те, що T є об’єктом. Воно не гарантує наявність конкретної властивості:
class Container<T extends object> {
// Неможливо безпечно звернутися до item.id
}Якщо контейнер використовує id, це потрібно явно відобразити в обмеженні:
class Container<T extends { readonly id: PropertyKey }> {
// Тепер item.id доступний типобезпечно
}anyТакий варіант вимикає переваги Generics:
function pluck<T>(items: T[], key: any): any[] {
return items.map((item) => item[key]);
}any дозволяє некоректні ключі та приховує тип результату. Для залежності між ключем і значенням потрібна конструкція:
function pluck<T extends object, K extends keyof T>(
items: readonly T[],
key: K
): T[K][] {
return items.map((item) => item[key]);
}Контейнер DataContainer<User> повинен містити саме User:
const users = new DataContainer<User>();
users.add({
id: 1,
name: "Олена",
role: "admin",
active: true,
});Якщо потрібно зберігати різні типи елементів, це має бути окремо описано через об’єднання типів, а не приховано за допомогою any.
Generic-клас може зберігати колекцію конкретного типу без дублювання реалізації.
Обмеження T extends ... гарантує наявність властивостей, потрібних контейнеру.
keyof T обмежує параметр лише ключами типу T.
T[K] отримує тип значення властивості, що відповідає ключу K.
Комбінація K extends keyof T і T[K] зберігає зв’язок між ключем та значенням.
Кілька параметрів типу дають змогу типізувати як елемент колекції, так і ключ або результат перетворення.
Generic-методи на кшталт findBy, pluck, groupBy дозволяють повторно використовувати реалізацію, не втрачаючи статичної типобезпеки.