Що таке generic у TypeScript
Generics у TypeScript - це параметри типу, які дозволяють написати функцію, клас або інтерфейс один раз і перевикористовувати для будь-якого типу, поки TypeScript зберігає повну перевірку типів на кожному виклику.
Теорія
TL;DR
- Generics - як форма для відливання: описуєш форму один раз (
<T>), заповнюєш будь-яким типом при використанні, отримуєш повну перевірку для цього типу - Головна різниця:
anyзнищує інформацію про тип; generics передають конкретний тип крізь весь ланцюжок - TypeScript сам виводить
Tз аргументів, явно писати<string>зазвичай не потрібно - Generics - коли одна логіка підходить для 2+ типів; конкретні типи - для однотипного коду
- Обмеження
T extends objectзвужують допустимі типи
Швидкий приклад
// Без generics: безпека типів втрачається
function first(arr: any[]): any {
return arr[0];
}
const val = first([1, 2, 3]); // val це 'any', без автодоповнення і перевірок
// val.toUpperCase(); // TypeScript мовчить, але впаде в runtime
// З generics: тип проходить крізь функцію
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = first([1, 2, 3]); // num це number
const str = first(['a', 'b']); // str це string
// num.toUpperCase(); // Помилка компіляції, TypeScript це зловитьT виводиться з аргументу автоматично. Писати first<number>([1, 2, 3]) явно зазвичай не потрібно.
Ключова різниця
Різниця між generics і any не просто стилістична. З any TypeScript взагалі припиняє перевіряти типи: немає автодоповнення, немає виявлення помилок. З generics TypeScript підставляє конкретний тип на кожному виклику. Пишеш first([1, 2, 3]) і TypeScript вважає кожне T у функції рівним number. Тип передається далі, а не зникає.
Коли використовувати
- Одна логіка для різних типів (операції з масивами, обгортки даних, хелпери для API): generics
- Тільки один конкретний тип: використовуй цей тип напряму
- Кілька типів на вході, однакова структура на виході: generics з обмеженнями (
T extends {id: string}) - Спільні утиліти по кодовій базі: generics (кожен модуль підставляє свій тип)
- Всього 2-3 відомі варіанти: перевантаження (overloads) можуть бути простішими
Як TypeScript обробляє generics
TypeScript розглядає T як абстрактний плейсхолдер під час оголошення, потім виконує підстановку типу на кожному виклику. Все це відбувається під час компіляції. В емітованому JavaScript від generics не залишається і сліду. Для T extends string компілятор звужує допустимі підстановки ще до генерації JS. Жодних витрат у runtime.
Типові помилки
Помилка: мутація generic-аргументу як ніби він any
// Неправильно
function append<T>(arr: T[]): T[] {
arr.push('extra' as any); // Ламає T[] у того хто викликав
return arr;
}
// Правильно
function append<T>(arr: T[], item: T): T[] {
return [...arr, item]; // Тип контролює той, хто викликає
}T зафіксований на момент виклику. Додавання string у T[] де T це number зламає код, навіть якщо TypeScript пропустив це за cast.
Помилка: забути обмеження для операцій з об'єктами
// Неправильно, T може бути примітивом і spread не спрацює
function merge<T>(a: T, b: T) {
return { ...a, ...b };
}
// Правильно
function merge<T extends object>(a: T, b: T) {
return { ...a, ...b };
}Помилка: немає аргументів для виведення типу
// Неправильно, T виводиться як never[]
function createArray<T>(): T[] { return []; }
const arr = createArray(); // never[]
// Правильно, аргумент допомагає вивести тип
function createArray<T>(size: number, fill: T): T[] {
return Array(size).fill(fill);
}
const nums = createArray(3, 0); // number[]Де зустрічається в реальних проектах
- React:
useRef<HTMLDivElement>()повертаєRefObject<HTMLDivElement>, точний тип DOM-елемента - React state:
useState<User | null>(null)типізований з самого початку, неany - TanStack Query:
useQuery<User[]>передає типи у всі селектори - Lodash типи:
_.map<T, R>(arr: T[], fn: (x: T) => R): R[]пов'язує типи входу і виходу - Express:
Request<P, ResBody, ReqBody>для типізованих обробників маршрутів
Питання на співбесіді
Q: Яка різниця між T extends object і T extends {}?
A: T extends {} підходить для всього крім null і undefined, включно з примітивами. T extends object також виключає примітиви. Для spread-операцій потрібен саме T extends object.
Q: Напиши функцію яка вибирає властивості об'єкта за масивом ключів.
A:
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
return keys.reduce((acc, k) => ({ ...acc, [k]: obj[k] }), {} as Pick<T, K>);
}K extends keyof T гарантує що передаються тільки валідні ключі. TypeScript зловить pick(user, ['salary']) якщо salary не є частиною типу.
Q: Чим TypeScript generics відрізняються від Java generics під час виконання?
A: TypeScript generics повністю стираються під час компіляції, у JS-виводі немає жодної інформації про типи. В Java в деяких випадках є reified generics, що дозволяють перевірки в runtime. В TypeScript це неможливо.
Q: Що робить infer в умовних типах?
A:
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
// UnpackPromise<Promise<string>> = string
// UnpackPromise<number> = numberinfer U каже TypeScript: витягни внутрішній тип із Promise<...> і запиши його в U для гілки true. Саме так побудований вбудований тип Awaited<T>.
Q: Junior vs senior на bounded generics: обмеж тип тільки рядками і числами.
A: Junior пише T extends string | number і це працює. Senior знає межі: якщо потрібна різна поведінка залежно від типу, перевантаження або умовні типи (T extends string ? string : number) дають більше контролю.
Приклади
Базовий: identity-функція з виведенням типу
function identity<T>(arg: T): T {
return arg;
}
const num = identity(42); // T виводиться як number
const str = identity('hello'); // T виводиться як string
const obj = identity({ id: 1 }); // T виводиться як { id: number }
// num.toUpperCase(); // Помилка компіляції, TypeScript знає що це number
console.log(num.toFixed(2)); // "42.00"TypeScript сам визначає T з аргументу. Явно писати identity<number>(42) не потрібно.
Середній: типізований доступ до властивостей з двома параметрами типу
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
interface Person {
name: string;
age: number;
}
const person: Person = { name: 'Alice', age: 30 };
console.log(getProp(person, 'name')); // "Alice", тип результату string
console.log(getProp(person, 'age')); // 30, тип результату number
// getProp(person, 'salary'); // Помилка компіляції: 'salary' немає в PersonДва параметри типу працюють разом: T це об'єкт, K обмежений ключами T. Тип результату T[K] це точний тип тієї властивості. Жодного приведення типів.
Просунутий: generic-хук для отримання даних
// Патерн з TanStack Query та власних fetch-оберток
function useData<T>(url: string): { data: T | null; loading: boolean } {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.finally(() => setLoading(false));
}, [url]);
return { data, loading };
}
interface User {
id: number;
name: string;
}
// T = User[], тому data це User[] | null
const { data: users, loading } = useData<User[]>('/api/users');
if (users) {
console.log(users[0].name); // Безпечно, TypeScript знає структуру
}Сам хук нічого не знає про User. Тип вибирає той, хто викликає. В кожному проекті де я писав власну fetch-обгортку, ми врешті-решт приходили саме до цього патерну: варто додати один <T> і все дерево компонентів отримує типізацію автоматично.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.