Алхімія Типів: Generics та Utility Types
Розділ 3: Generics та Utility Types
Якщо інтерфейси — це цегла, то Generics (узагальнення) — це цемент, який дозволяє будувати форми будь-якої складності.
Це тема, де початківці часто ламаються, бо синтаксис <T> виглядає лякаюче. Але насправді все просто: Generics — це аргументи для типів.
1. Проблема "Echo"
Уявіть, що вам потрібна функція, яка повертає те, що їй передали.
function echo(arg: number): number {
return arg
}
Але що, якщо ми хочемо передати рядок? Писати echoString? А потім echoBoolean?
Можна використати any:
function echo(arg: any): any {
return arg
}
const result = echo('Hello')
// result має тип any. Ми втратили контроль!
// result.toFixed() // TS промовчить, а в Runtime буде ба-бах.
Нам потрібен спосіб сказати: "Я не знаю, який тип прийде, але я хочу, щоб результат був того ж типу, що й аргумент".
Рішення: Generic Function
function echo<T>(arg: T): T {
return arg
}
Давайте розберемо цей синтаксис:
<T>: Ми оголошуємо "змінну типу" T. (T — це стандарт, якiв циклах, скорочення від Type).arg: T: Аргумент буде мати тип T.: T: Функція поверне тип T.
const str = echo('Hello') // T стає 'string'. Тип str: string.
const num = echo(42) // T стає 'number'. Тип num: number.
// str.toFixed() // 🛑 Error! TS знає, що str - це рядок.
Ми досягли ідеалу: код працює з будь-чим, але типи зберігаються.
2. Generic Syntax: Більше ніж <T>
Ви можете мати кілька параметрів.
function pair<T, U>(a: T, b: U): [T, U] {
return [a, b]
}
const result = pair('Age', 25) // [string, number]
Ви можете використовувати їх в Arrow Functions (обережно з .tsx файлами, там <T> може сплутатись з тегом).
const echoArrow = <T>(arg: T): T => arg
// У .tsx файлах часто треба ставити кому, щоб компілятор зрозумів
const echoTsx = <T>(arg: T): T => arg
3. Generic Constraints (Обмеження)
Іноді ми хочемо, щоб T було не "будь-чим", а "чимось, що має певні властивості".
Задача: Функція, яка логує довжину аргументу.
function logLength<T>(arg: T) {
// console.log(arg.length); // 🛑 Error: Property 'length' does not exist on type 'T'.
}
Чому помилка? Бо T може бути number або boolean, у яких немає length.
Нам треба обмежити T.
interface Lengthwise {
length: number
}
function logLength<T extends Lengthwise>(arg: T): T {
console.log(arg.length) // ✅ Тепер ОК
return arg
}
logLength('Hello') // ✅ string має length
logLength([1, 2, 3]) // ✅ array має length
logLength({ length: 10, value: 'something' }) // ✅ об'єкт з length
// logLength(42); // 🛑 Error: number не має length
Ключове слово extends тут означає "є підтипом" або "відповідає формі".
4. Generic Classes
Класи теж можуть бути узагальненими. Класичний приклад — "Скринька" або "Кеш".
class StorageBox<T> {
private _contents: T
constructor(initialValue: T) {
this._contents = initialValue
}
get(): T {
return this._contents
}
set(val: T): void {
this._contents = val
}
}
const stringBox = new StorageBox<string>('Initial')
// stringBox.set(123); // 🛑 Error
const numberBox = new StorageBox<number>(0)
numberBox.set(100) // ✅
5. Типові Utility Types (Вбудовані)
TypeScript дає нам набір інструментів ("молоток", "викрутка", "пила") для роботи з типами. Вам не треба писати їх вручну, вони вже є в глобальному скоупі. Але ми розберемо, як вони працюють всередині, щоб ви зрозуміли механіку.
Partial<T>
Робить всі поля необов'язковими.
interface User {
id: number
name: string
email: string
}
// Потрібно для функції оновлення (patch)
function updateUser(id: number, changes: Partial<User>) {
// ...
}
updateUser(1, { email: 'new@example.com' }) // name не обов'язкове
Як це працює?
type MyPartial<T> = {
[P in keyof T]?: T[P]
}
Required<T>
Навпаки: робить всі поля обов'язковими (прибирає ?).
interface Props {
theme?: 'dark' | 'light' // за замовчуванням "light"
}
// Всередині компонента ми хочемо працювати з повним об'єктом
const defaults: Required<Props> = {
theme: 'light',
}
Як це працює?
type MyRequired<T> = {
[P in keyof T]-?: T[P] // -? видаляє модифікатор optional
}
Readonly<T>
Робить всі поля доступними тільки для читання.
const config: Readonly<User> = {
id: 1,
name: 'Admin',
email: 'admin@test.com',
}
// config.id = 2; // 🛑 Error
Record<K, T>
Створює об'єкт, де ключі мають тип K, а значення — T.
type Page = 'home' | 'about' | 'contact'
const navTitles: Record<Page, string> = {
home: 'Головна',
about: 'Про нас',
contact: 'Контакти',
// Якщо забути якийсь ключ з Page -> TS нагадає!
}
Це набагато безпечніше, ніж {[key: string]: string}.
Pick<T, K>
Вибирає підмножину полів.
// Ми хочемо показати тільки ім'я в списку
type UserPreview = Pick<User, 'id' | 'name'>
// { id: number; name: string; }
Omit<T, K>
Викидає поля.
// Для створення користувача ID генерується на сервері, тому ми його прибираємо
type CreateUserDTO = Omit<User, 'id'>
// { name: string; email: string; }
Pick ("Білий список"), якщо список полів короткий. Це безпечніше: якщо ви додасте нове чутливе поле в User (наприклад, passwordHash), Pick його проігнорує. А Omit автоматично включить його, що може призвести до витоку даних.6. Умовні Типи (Conditional Types)
Це if-else у світі типів.
Синтаксис: T extends U ? TrueType : FalseType.
type TypeName<T> = T extends string
? 'string'
: T extends number
? 'number'
: T extends boolean
? 'boolean'
: T extends undefined
? 'undefined'
: 'object'
type T1 = TypeName<string> // "string"
type T2 = TypeName<() => void> // "object"
Exclude<T, U>
Видаляє з T все, що підходить під U.
type Status = 'success' | 'warning' | 'error'
// Хочемо тільки статуси для "успіху" або "попередження"
type NonErrorStatus = Exclude<Status, 'error'> // "success" | "warning"
Реалізація:
type MyExclude<T, U> = T extends U ? never : T
// Якщо T підходить під U -> перетворюється на never (зникає з Union)
// Інакше -> залишається
Extract<T, U>
Навпаки: залишає тільки те, що підходить під U.
type T0 = Extract<'a' | 'b' | 'c', 'a' | 'f'> // "a"
NonNullable<T>
Прибирає null і undefined.
type MaybeString = string | null | undefined
type DefinitelyString = NonNullable<MaybeString> // string
7. Робота з Функціями: ReturnType та Parameters
Іноді нам треба дістати типи з самої функції.
function createUser(name: string, role: 'admin' | 'user') {
return { id: 123, name, role, createdAt: new Date() }
}
// Який тип повертає ця функція? Ми не хочемо описувати інтерфейс вручну.
type UserModel = ReturnType<typeof createUser>
// { id: number; name: string; ... }
// А які параметри вона приймає?
type CreateParams = Parameters<typeof createUser>
// [name: string, role: "admin" | "user"] (Tuple)
Це критично важливо для Redux Saga, Thunks або middleware, де ви обгортаєте функції.
Awaited<T>
Розгортає Promise.
type FetchUserPromise = Promise<User>
type UserData = Awaited<FetchUserPromise> // User
Це дуже корисно, коли ви отримуєте тип відповіді від асинхронної функції.
async function getData() {
return { data: 'secret' }
}
type APIResponse = Awaited<ReturnType<typeof getData>>
// { data: string }
8. Практичний Кейс: Типізований fetch (Generic wrapper)
Давайте напишемо функцію http, яка повертає типізовані дані.
interface APIError {
message: string
code: number
}
// Generic interface for JSON response
interface APIResponse<T> {
data: T
metadata: {
page: number
total: number
}
}
// data: T = unknown за замовчуванням (поки ми не вказали тип)
async function http<T = unknown>(url: string): Promise<APIResponse<T>> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(response.statusText)
}
// const body = await response.json();
// return body as APIResponse<T>; // Небезпечне місце!
// Більш безпечно (але все ще довіряємо бекенду)
return response.json()
}
// Використання
interface User {
id: number
username: string
}
async function main() {
// Ми явно кажемо: ми очікуємо User[]
const result = await http<User[]>('/api/users')
// result.data - це User[]
result.data.forEach((u) => console.log(u.username))
}
9. Магія infer: Витягування типів з нізвідки
Ключове слово infer працює тільки всередині умови (extends).
Воно дозволяє "оголосити змінну типу" прямо всередині перевірки.
Задача: Дістати тип першого аргументу функції
type FirstArg<T> = T extends (first: infer U, ...args: any[]) => any ? U : never
function greet(name: string, age: number) {}
type NameType = FirstArg<typeof greet> // string
Як це працює: TS дивиться на T. Якщо T — це функція, він "захоплює" тип першого аргументу в змінну U і повертає U.
Розбір ReturnType (Вбудований)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : any
Якщо T функція -> поверни її результат R.
10. Distributive Conditional Types (Розподільні Умовні Типи)
Це звучить складно, але це пояснює "дивну" поведінку Generics з Union Types.
type ToArray<T> = T extends any ? T[] : never
type StrArr = ToArray<string> // string[]
type UnionArr = ToArray<string | number>
// Очікування: (string | number)[]
// Реальність: string[] | number[]
Чому?
Коли ви передаєте Union (A | B) в Generic, TS розбиває його і застосовує логіку до кожного члена окремо:
ToArray<string> | ToArray<number>string[] | number[]
Як це вимкнути?
Огорніть типи у квадратні дужки [].
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never
type Result = ToArrayNonDist<string | number>
// (string | number)[]
11. Mapped Types: Advanced
Ми бачили Pick і Record. Але ми можемо створювати свої мапери.
Задача: Зробити всі методи класу асинхронними
type Asyncify<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R
? (...args: A) => Promise<R> // Якщо це функція -> обгорни результат в Promise
: T[K] // Якщо це поле -> залиш як є
}
interface API {
loadUsers(): User[]
timeout: number
}
type AsyncAPI = Asyncify<API>
// {
// loadUsers(): Promise<User[]>;
// timeout: number;
// }
Зміна назв ключів (Key Remapping as)
Починаючи з TS 4.1.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
interface Person {
name: string
age: number
}
type PersonGetters = Getters<Person>
// {
// getName: () => string;
// getAge: () => number;
// }
12. Recursive Types: DeepPartial
Partial<T> робить опціональним тільки верхній рівень. А якщо у нас вкладений об'єкт?
interface DeepConfig {
db: {
host: string
port: number
}
}
const conf: Partial<DeepConfig> = {
db: { host: 'localhost' }, // 🛑 Error: Property 'port' is missing
}
Рішення: Рекурсія.
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
const deepConf: DeepPartial<DeepConfig> = {
db: { host: 'localhost' }, // ✅ Тепер працює, бо db теж DeepPartial
}
13. Variadic Tuple Types (Варіативні Кортежі)
TypeScript дозволяє працювати з хвостами масивів.
// Функція, яка додає елемент на початок кортежу
function prepend<T, U extends any[]>(head: T, tail: [...U]): [T, ...U] {
return [head, ...tail]
}
const t1 = [2, 3] as [number, number]
const t2 = prepend(1, t1) // [1, 2, 3] -> [number, number, number]
Це основа типізації функції concat та каррірування (currying).
14. Mixin Pattern
Mixin — це функція, яка приймає клас і повертає новий клас з розширеним функціоналом.
// Конструктор класу (будь-якого)
type Constructor<T = {}> = new (...args: any[]) => T
// Mixin, який додає поле timestamp
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
timestamp = new Date()
}
}
class User {
name = 'Alice'
}
// Створюємо новий клас
const TimestampedUser = Timestamped(User)
const user = new TimestampedUser()
console.log(user.name) // Alice
console.log(user.timestamp) // Date
15. The satisfies Operator (TS 4.9)
Це новий оператор, який вирішує проблему втрати точного типу при вказанні інтерфейсу.
interface Config {
colors: Record<string, string | { r: number; g: number; b: number }>
}
// Cтарий підхід
const theme: Config = {
colors: {
red: '#ff0000',
green: { r: 0, g: 255, b: 0 },
},
}
// theme.colors.red.toUpperCase(); // 🛑 Error: Property 'toUpperCase' does not exist on type 'string | { r... }'.
// Новий підхід
const themeSatisfies = {
colors: {
red: '#ff0000',
green: { r: 0, g: 255, b: 0 },
},
} satisfies Config
// ✅ TS перевірив, що об'єкт підходить під Config,
// АЛЕ зберіг точний тип значень.
themeSatisfies.colors.red.toUpperCase()
16. Анти-патерни Generics
"Золотий Молоток" (Unnecessary Generic)
// ❌ Погано: T ніяк не пов'язаний з результатом
function log<T>(arg: T) {
console.log(arg)
}
// ✅ Добре
function log(arg: unknown) {
console.log(arg)
}
Використовуйте Generic тільки якщо:
- Тип повернення залежить від типу аргументу.
- Типи двох аргументів залежать один від одного.
"Any в овечій шкурі"
// ❌ Погано: T extends any - це масло масляне
function foo<T extends any>(arg: T) { ... }
17. Практичне Завдання: Event Bus (Pub/Sub)
Створіть типізовану шину подій. Це класична задача на інтерв'ю.
Вимоги
- Описати карту подій.
interface Events { login: { userId: number } logout: void error: { message: string; code: number } } - Клас
EventBus. - Метод
on<K extends keyof Events>(event: K, cb: (payload: Events[K]) => void): void. - Метод
emit<K extends keyof Events>(event: K, payload: Events[K]): void.
Реалізація (Каркас)
class TypedEventBus<EventMap> {
private listeners: { [K in keyof EventMap]?: Function[] } = {}
on<K extends keyof EventMap>(key: K, cb: (payload: EventMap[K]) => void) {
// ...
}
emit<K extends keyof EventMap>(key: K, payload: EventMap[K]) {
// ...
}
}
Спробуйте реалізувати це так, щоб:
bus.emit("login", { userId: 1 })працювало.bus.emit("login", { user: "admin" })видавало помилку.bus.emit("logout")не вимагало payload (це складна частина, можливо доведеться використати перевантаження або conditional types для void).
18. Шпаргалка (Cheatsheet)
| Синтаксис | Назва | Що робить |
|---|---|---|
<T> | Generic Type Var | Аргумент для типу. |
T extends U | Constraint | T має бути підтипом U. |
keyof T | Index Type | Union всіх ключів T. |
T[K] | Indexed Access | Тип значення по ключу K. |
[P in K] | Mapped Type | Ітерація по ключах (як for-in). |
infer R | Inference | "Вгадай" тип R всередині умови. |
Partial<T> | Utility | Всі поля Optional. |
Pick<T, K> | Utility | Взяти тільки ключі K. |
Omit<T, K> | Utility | Викинути ключі K. |
Record<K, T> | Utility | Об'єкт з ключами K і значеннями T. |
ReturnType | Utility | Тип, що повертає функція. |
19. Case Study: Typed Query Builder
Давайте напишемо Fluent API для побудови SQL запитів, де ми не можемо "вибрати" поле, якого немає.
// Наша "таблиця"
interface UserTable {
id: number
username: string
email: string
isActive: boolean
}
class QueryBuilder<T, SelectedKeys extends keyof T = never> {
constructor(private readonly data: T[]) {}
// K додається до SelectedKeys
select<K extends keyof T>(...keys: K[]): QueryBuilder<T, SelectedKeys | K> {
return this as any // Імітація
}
// Де condition - це Partial від вибраних ключів або всіх?
// Давайте спростимо: where по будь-якому полю
where<K extends keyof T>(key: K, value: T[K]): this {
return this
}
// execute повертає масив об'єктів тільки з вибраними ключами!
execute(): Pick<T, SelectedKeys>[] {
return [] // Імітація
}
}
const qb = new QueryBuilder<UserTable>([])
const result = qb.select('id', 'username').where('isActive', true).execute()
// result[0].id; // ✅
// result[0].username; // ✅
// result[0].email; // 🛑 Error: Property 'email' does not exist
Це демонстрація того, як тип може "накопичуватися" під час ланцюжка викликів (SelectedKeys | K).
20. Type Gymnastics (Гімнастика Типів)
Тут ми просто флексимо можливостями TS. Це корисно для бібліотек.
Reverse Tuple
Як розвернути кортеж?
type Reverse<T extends any[]> = T extends [infer Head, ...infer Tail] ? [...Reverse<Tail>, Head] : []
type A = Reverse<[1, 2, 3]> // [3, 2, 1]
KebabCase
Перетворення "CamelCase" в "kebab-case".
type KebabCase<S extends string> = S extends `${infer T}${infer U}`
? U extends Uncapitalize<U>
? `${Uncapitalize<T>}${KebabCase<U>}`
: `${Uncapitalize<T>}-${KebabCase<U>}`
: ''
type K = KebabCase<'MySuperComponent'> // "my-super-component"
Навіщо це? Для типізації props у Vue/React компонентах, які дозволяють kabab-case.
DeepReadonly (Рекурсивний)
Ми вже писали DeepPartial. Це аналог.
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]
}
21. Phantom Types (Branded Types)
Іноді нам треба розрізняти два рядки на рівні типів, хоча в Runtime це просто рядки.
Наприклад, UserId і OrderId.
// Це не спрацює, бо для TS це просто string
// type UserId = string;
// type OrderId = string;
Використаємо "фантомне" поле, якого не існує в Runtime.
declare const __brand: unique symbol
type Brand<T, B> = T & { [__brand]: B }
type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
function getUser(id: UserId) {
/* ... */
}
function getOrder(id: OrderId) {
/* ... */
}
const uid = 'user_123' as UserId
const oid = 'order_456' as OrderId
getUser(uid) // ✅
// getUser(oid); // 🛑 Error: Type 'OrderId' is not assignable to type 'UserId'.
// getUser("simple_string"); // 🛑 Error
Це патерн, який використовують Zod, io-ts та інші бібліотеки валідації. Це гарантує, що рядок пройшов валідацію.
Резюме Розділу
Generics — це вершина айсберга TypeScript.
Якщо ви зрозуміли infer, Mapped Types та Conditional Types — ви знаєте TS краще за 90% розробників.
- Generics роблять код перевикористовуваним.
- Constraints роблять його безпечним.
- Utility Types економлять ваш час.
Далі ми пірнемо в ООП. Ви дізнаєтесь, чому private в TS — це не private в JS, і як використовувати абстрактні класи для чистої архітектури.
Майстерність Моделювання Даних: Інтерфейси та Просунуті Типи
Як описати реальний світ у коді. Interfaces vs Types, Union Types, Discriminated Unions та мистецтво створення неможливих станів.
Архітектура та Шаблони: Класи в TypeScript
ООП в світі JavaScript. Модифікатори доступу, абстрактні класи, декоратори та чому 'private' не завжди приватний.