Skip to main content

Що таке generic у TypeScript

Generics у TypeScript - це параметри типу, які дозволяють написати функцію, клас або інтерфейс один раз і перевикористовувати для будь-якого типу, поки TypeScript зберігає повну перевірку типів на кожному виклику.

Теорія

TL;DR

  • Generics - як форма для відливання: описуєш форму один раз (<T>), заповнюєш будь-яким типом при використанні, отримуєш повну перевірку для цього типу
  • Головна різниця: any знищує інформацію про тип; generics передають конкретний тип крізь весь ланцюжок
  • TypeScript сам виводить T з аргументів, явно писати <string> зазвичай не потрібно
  • Generics - коли одна логіка підходить для 2+ типів; конкретні типи - для однотипного коду
  • Обмеження T extends object звужують допустимі типи

Швидкий приклад

typescript
// Без 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

typescript
// Неправильно 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.

Помилка: забути обмеження для операцій з об'єктами

typescript
// Неправильно, T може бути примітивом і spread не спрацює function merge<T>(a: T, b: T) { return { ...a, ...b }; } // Правильно function merge<T extends object>(a: T, b: T) { return { ...a, ...b }; }

Помилка: немає аргументів для виведення типу

typescript
// Неправильно, 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:

typescript
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:

typescript
type UnpackPromise<T> = T extends Promise<infer U> ? U : T; // UnpackPromise<Promise<string>> = string // UnpackPromise<number> = number

infer 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-функція з виведенням типу

typescript
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) не потрібно.

Середній: типізований доступ до властивостей з двома параметрами типу

typescript
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-хук для отримання даних

typescript
// Патерн з 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> і все дерево компонентів отримує типізацію автоматично.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?