TypeScript

Алхімія Типів: Generics та Utility Types

Як писати код, який працює з будь-чим. Створюємо власні 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
}

Давайте розберемо цей синтаксис:

  1. <T>: Ми оголошуємо "змінну типу" T. (T — це стандарт, як i в циклах, скорочення від Type).
  2. arg: T: Аргумент буде мати тип T.
  3. : 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; }
Omit vs Pick Завжди краще використовувати 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 тільки якщо:

  1. Тип повернення залежить від типу аргументу.
  2. Типи двох аргументів залежать один від одного.

"Any в овечій шкурі"

// ❌ Погано: T extends any - це масло масляне
function foo<T extends any>(arg: T) { ... }

17. Практичне Завдання: Event Bus (Pub/Sub)

Створіть типізовану шину подій. Це класична задача на інтерв'ю.

Вимоги

  1. Описати карту подій.
    interface Events {
        login: { userId: number }
        logout: void
        error: { message: string; code: number }
    }
    
  2. Клас EventBus.
  3. Метод on<K extends keyof Events>(event: K, cb: (payload: Events[K]) => void): void.
  4. Метод 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 UConstraintT має бути підтипом U.
keyof TIndex TypeUnion всіх ключів T.
T[K]Indexed AccessТип значення по ключу K.
[P in K]Mapped TypeІтерація по ключах (як for-in).
infer RInference"Вгадай" тип R всередині умови.
Partial<T>UtilityВсі поля Optional.
Pick<T, K>UtilityВзяти тільки ключі K.
Omit<T, K>UtilityВикинути ключі K.
Record<K, T>UtilityОб'єкт з ключами K і значеннями T.
ReturnTypeUtilityТип, що повертає функція.

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% розробників.

  1. Generics роблять код перевикористовуваним.
  2. Constraints роблять його безпечним.
  3. Utility Types економлять ваш час.

Далі ми пірнемо в ООП. Ви дізнаєтесь, чому private в TS — це не private в JS, і як використовувати абстрактні класи для чистої архітектури.

Далі: Класи та ООП

Модифікатори доступу, абстрактні класи та патерни.
Copyright © 2026