TypeScript

Майстерність Моделювання Даних: Інтерфейси та Просунуті Типи

Як описати реальний світ у коді. Interfaces vs Types, Union Types, Discriminated Unions та мистецтво створення неможливих станів.

Розділ 2: Інтерфейси та Просунуті Типи

Ми навчилися створювати "цеглинки" (primitive types). Тепер час будувати "будинки" (complex structures). У JavaScript майже все є об'єктом. У TypeScript вміння описувати форму об'єкта — це 80% успіху.

Ми переходимо від "перевірки типів" до "моделювання домену".


1. Світ Об'єктів: Interface vs Type Alias

Це вічна війна. "Що використовувати?". Давайте розберемося раз і назавжди.

Interface: Контракт для об'єктів

Інтерфейс — це спосіб описати форму об'єкта. Він каже: "Я очікую об'єкт, у якого є такі поля".

interface User {
    id: number
    username: string
    isActive: boolean
}

const admin: User = {
    id: 1,
    username: 'admin',
    isActive: true,
}

Declaration Merging (Злиття Декларацій)

Це унікальна фіча інтерфейсів. Якщо ви оголосите два інтерфейси з одним іменем, вони зіллються в один.

// Файл 1 (або бібліотека)
interface Settings {
    theme: 'dark' | 'light'
}

// Файл 2 (ваше розширення)
interface Settings {
    fontSize: number
}

// Результат: Settings тепер має ОБИДВА поля
const config: Settings = {
    theme: 'dark',
    fontSize: 14,
}

Це критично важливо для розширення бібліотек (наприклад, додавання полів до Window або Request в Express).

Type Alias: Псевдонім для будь-чого

type — це просто інша назва для будь-якого типу. Це може бути об'єкт, примітив, функція або Union.

// 1. Примітив
type ID = string

// 2. Union (Об'єднання)
type Status = 'loading' | 'success' | 'error'

// 3. Функція
type Handler = (event: Event) => void

// 4. Об'єкт (схоже на Interface)
type UserType = {
    id: ID
    status: Status
}
Type Aliases НЕ зливаються. Якщо ви спробуєте оголосити type Settings двічі — отримаєте помилку Duplicate identifier.

Що обрати?

ХарактеристикаInterfaceType Alias
Об'єкти✅ Так✅ Так
Примітиви❌ Ні✅ Так
Union Types❌ Ні✅ Так
Declaration Merging✅ Так (для бібліотек)❌ Ні
implements в класах✅ Так✅ Так (обмежено)
Золоте правило: Використовуйте interface для опису об'єктів та публічних API (щоб їх могли розширювати інші). Використовуйте type для Union Types, функцій, примітивів та складних маніпуляцій з типами. Якщо сумніваєтесь — починайте з interface.

2. Анатомія Об'єктного Типу

Описувати прості поля нудно. Давайте глянемо на реальні сценарії.

Optional Properties (Необов'язкові поля)

Іноді поле може бути, а може й ні.

interface Product {
    name: string
    description?: string // string | undefined
}

// ✅ Обидва варіанти валідні
const p1: Product = { name: 'iPhone' }
const p2: Product = { name: 'Samsung', description: 'Android phone' }

// ⚠️ Доступ до optional поля
// console.log(p1.description.toUpperCase()); // Error: Object is possibly 'undefined'.
console.log(p1.description?.toUpperCase()) // Optional Chaining to the rescue!

Readonly Properties (Тільки для читання)

Імутабельність — друг стабільності.

interface UserProfile {
    readonly id: number // Не можна змінити після створення
    name: string
}

const user: UserProfile = { id: 101, name: 'Alice' }
user.name = 'Bob' // ✅ ОК
// user.id = 102; // 🛑 Error: Cannot assign to 'id' because it is a read-only property.

Shallow Readonly (Поверхнева незмінність)

Це критично важливий момент. Модифікатор readonly у TypeScript працює лише на першому рівні вкладеності.

Якщо властивість об'єкта сама є об'єктом, то TypeScript заборонить вам замінити цей вкладений об'єкт цілком, але дозволить змінювати його внутрішні поля.

interface Address {
    city: string
    street: string
}

interface UserProfile {
    readonly id: number
    readonly address: Address // Весь об'єкт address "readonly"
    name: string
}

const user: UserProfile = {
    id: 1,
    name: 'Alice',
    address: {
        city: 'Kyiv',
        street: 'Khreschatyk',
    },
}

// 🛑 Помилка: id тільки для читання
// user.id = 2;

// 🛑 Помилка: ми не можемо ПЕРЕЗАПИСАТИ весь об'єкт address
// user.address = { city: 'Lviv', street: 'Rynok' };

// ✅ ОК: Але ми МОЖЕМО змінити поле всередині об'єкта address!
user.address.city = 'Lviv'
Чому так? TypeScript перевіряє лише посилання. Властивість address зберігає посилання на об'єкт. Ви не можете змінити посилання (присвоїти новий об'єкт), але сам об'єкт у пам'яті залишається мутабельним.

Якщо ви хочете повної, глибокої незмінності, вам потрібно або позначати кожне вкладене поле як readonly, або використовувати утиліту Readonly<T> (про яку ми поговоримо в розділі про Generics).


Index Signatures (Динамічні ключі)

Що робити, якщо ми не знаємо назв полів заздалегідь? Наприклад, кеш або словник.

interface Cache {
    [key: string]: any // Ключ - рядок, значення - будь-що
}

const memory: Cache = {}
memory['user_1'] = 'Alice'
memory['config'] = { theme: 'dark' }

// Але це небезпечно! TS не перевіряє доступ.
// const item = memory["non_existent"]; // Typs: any. Runtime: undefined.

Краще використовувати Record<K, V> (про це пізніше).


3. Union Types (Об'єднання)

Це "Святий Грааль" моделювання станів у TypeScript. Union означає "АБО". Змінна може бути або типом A, або типом B.

type ID = string | number

function printId(id: ID) {
    console.log(`Your ID is: ${id}`)

    // 🛑 Error: Property 'toUpperCase' does not exist on type 'string | number'.
    // id.toUpperCase();

    // ✅ Type Narrowing (Звуження типу)
    if (typeof id === 'string') {
        console.log(id.toUpperCase()) // Тут id - це точно string
    } else {
        console.log(id.toFixed(2)) // Тут id - це точно number
    }
}
Спільні поля (Common Fields) Якщо у вас є Union об'єктів, ви можете напряму звертатися ТІЛЬКИ до тих полів, які є у ВСІХ варіантах об'єднання.
interface Bird {
    fly(): void
    layEggs(): void
}
interface Fish {
    swim(): void
    layEggs(): void
}

function getSmallPet(): Bird | Fish {
    /* ... */
}

const pet = getSmallPet()
pet.layEggs() // ✅ Є у обох
// pet.fly(); // 🛑 Error: Fish does not fly!

4. Intersection Types (Перетин)

Intersection означає "І". Ми склеюємо типи разом.

interface HasName {
    name: string
}
interface HasAge {
    age: number
}

type Person = HasName & HasAge

const bob: Person = {
    name: 'Bob',
    age: 30,
    // Якщо забути хоч одне поле - буде помилка
}

Це часто використовується для композиції.

type APIResponse = {
    data: User[]
    page: number
}

type ErrorResponse = {
    error: string
    code: number
}

// Це навряд чи має сенс (відповідь не може бути одночасно успішною І поламаною)
type Wtf = APIResponse & ErrorResponse

5. Discriminated Unions (Дискриміновані Об'єднання)

Це "коронна фіча" системи типів TypeScript. Якщо ви зрозумієте цей патерн, якість вашої архітектури зросте вдесятеро.

У чому проблема звичайних Union?

Уявіть, що ви описуєте фігури:

interface Shape {
    kind: string
    radius?: number // Тільки для кола
    sideLength?: number // Тільки для квадрата
}

Це погано, бо TS не знає, що radius існує тільки тоді, коли kind === "circle". Вам доведеться постійно використовувати !, заводити перевірки на undefined або робити as.

Рішення: Discriminated Union

Щоб створити Дискриміноване Об'єднання, потрібні три інгредієнти:

  1. Типи, які мають спільну назву властивості (напркилад, type, status або kind).
  2. Ця властивість має бути Literal Type (конкретне значення).
  3. Union цих типів.

Давайте перепишемо фігури:

interface Circle {
    kind: 'circle' // Дискримінант
    radius: number
}

interface Square {
    kind: 'square' // Дискримінант
    sideLength: number
}

interface Rectangle {
    kind: 'rectangle'
    width: number
    height: number
}

type Shape = Circle | Square | Rectangle

Тепер TypeScript розуміє взаємозв'язок між значенням kind та наявністю інших полів.

Як це працює (Narrowing)

function getArea(shape: Shape) {
    switch (shape.kind) {
        case 'circle':
            // Тут shape — це точно Circle. Радіус доступний!
            return Math.PI * shape.radius ** 2
        case 'square':
            // Тут shape — це точно Square. sideLength доступний!
            return shape.sideLength ** 2
        case 'rectangle':
            return shape.width * shape.height
    }
}

Перевага: Exhaustiveness Checking (Перевірка на повноту)

Це одна з найбільш недооцінених фішок. Що, якщо ми додамо новий тип фігури Triangle, але забудемо оновити функцію getArea?

Ми можемо змусити TypeScript сваритися на нас за допомогою типу never.

function getArea(shape: Shape) {
    switch (shape.kind) {
        case 'circle':
            return Math.PI * shape.radius ** 2
        case 'square':
            return shape.sideLength ** 2
        case 'rectangle':
            return shape.width * shape.height
        default:
            // Якщо ми обробили всі типи, то сюди ми ніколи не потрапимо.
            // Отже, тип shape тут має бути 'never'.
            const _exhaustiveCheck: never = shape
            return _exhaustiveCheck
    }
}

Якщо ви додасте Triangle у type Shape, але не додасте case "triangle" у switch, TypeScript видасть помилку: Type 'Triangle' is not assignable to type 'never'. Це гарантує, що ви ніколи не забудете обробити новий стан системи.

Приклад 2: Обробка результатів API

Це найчастіший сценарій у реальній розробці.

type ApiResponse<T> =
    | { status: 'loading' }
    | { status: 'success'; data: T; timestamp: number }
    | { status: 'error'; error: Error; code: number }

function handleResponse(res: ApiResponse<string[]>) {
    if (res.status === 'success') {
        console.log('Дані отримано:', res.data) // OK
        // console.log(res.error); // Error: Поля error не існує в успішній відповіді
    }
}

Приклад 3: Стан інтерфейсу (UI State)

Цей приклад ідеально демонструє, як Дискриміновані Об'єднання допомагають уникати логічних помилок при рендерингу.

// 1. Описуємо кожен стан окремо для чистоти
interface LoadingState {
    status: 'loading'
}

interface SuccessState {
    status: 'success'
    data: string[]
}

interface ErrorState {
    status: 'error'
    message: string
}

// 2. Створюємо Union
type UIState = LoadingState | SuccessState | ErrorState

// 3. Функція рендерингу, яка ніколи не помилиться
function renderPage(state: UIState) {
    if (state.status === 'loading') {
        return 'Завантаження... 🔄'
    }

    if (state.status === 'error') {
        // Тут TypeScript знає, що є message, але НЕМАЄ data
        return `Помилка: ${state.message} ❌`
    }

    if (state.status === 'success') {
        // Тут TypeScript знає, що є data, але НЕМАЄ message
        return `Дані: ${state.data.join(', ')} ✅`
    }
}
Чому це краще за Boolean прапорці? Коли ви використовуєте isLoading, isError, data як окремі стейти (наприклад, у React useState), ви можете випадково отримати стан isLoading: true ТА data: [...] одночасно. Дискриміноване об'єднання робить такі "неможливі стани" неможливими на рівні типів.

6. Literal Types (Літеральні Типи)

Ми вже бачили їх вище ("loading", "success"). Це типи, які допускають лише конкретне значення, а не весь клас значень.

// String Literal
type Direction = 'North' | 'South' | 'East' | 'West'
function move(dir: Direction) {}
move('North') // ✅
// move("Up"); // 🛑 Error

// Numeric Literal
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6

// Boolean Literal
type IsEnabled = true // Змінна може бути ТІЛЬКИ true

Template Literal Types (Детально)

Ви можете комбінувати літерали.

type Color = 'red' | 'blue'
type Variant = 'light' | 'dark'

// Автоматична генерація всіх комбінацій:
// "red-light" | "red-dark" | "blue-light" | "blue-dark"
type ThemeClass = `${Color}-${Variant}`

Це використовується в дизайн-системах.

interface ButtonProps {
    size: 'sm' | 'md' | 'lg'
    variant: 'primary' | 'secondary' | 'danger'
}
// 9 можливих комбінацій

7. Enums vs Objects (Вічна дискусія)

У більшості мов (C#, Java, Swift) перерахування (Enums) — це база. У TypeScript (enum) — це одна з небагатьох фіч, яка додає реальний код у JavaScript під час компіляції. І саме тому навколо них стільки суперечок.

Що таке Enum і навіщо він потрібен?

Enum (Enumeration) — це набір іменованих констант. Він допомагає замінити "магічні рядки" або "магічні числа" на зрозумілі імена, групуючи їх в одну сутність.

Кейси використання:

  • Ролі користувачів (ADMIN, USER).
  • Статуси замовлення (PENDING, SHIPPED).
  • Напрямки (UP, DOWN, LEFT, RIGHT).
  • Дні тижня або місяці.

1. Числові Enums (Numeric Enums)

Це варіант за замовчуванням. Якщо не вказувати значення, TS почне з 0 і буде інкрементувати їх.

enum UserRole {
    Admin, // 0
    Editor, // 1
    User, // 2
}

const myRole: UserRole = UserRole.Admin // 0

🛑 Критична проблема: "Number Hole"

До версії TS 5.0 числові енуми мали "дірку" в безпеці: ви могли присвоїти змінній типу UserRole будь-яке число, навіть якщо його немає в списку.

const role: UserRole = 123 // ⚠️ Жодних помилок від компілятора!

Це ламає саму ідею типізації, бо функція, яка очікує UserRole, може отримати 123 і впасти під час виконання.


2. Рядкові Enums (String Enums)

Вони з'явилися пізніше, щоб зробити код більш читабельним під час налагодження (debug).

enum OrderStatus {
    Pending = 'PENDING',
    Shipped = 'SHIPPED',
    Delivered = 'DELIVERED',
}

🛑 Проблема: Runtime Overhead та Tree-shaking

На відміну від інтерфейсів чи типів, які зникають після компіляції, enum перетворюється на об'єкт.

Код TS:

enum Color {
    Red = 'red',
}

Код JS після компіляції:

var Color
;(function (Color) {
    Color['Red'] = 'red'
})(Color || (Color = {}))

Це "IIFE" (Immediately Invoked Function Expression). Проблема в тому, що сучасні збирачі проектів (Vite, Webpack) часто не можуть видалити цей код, навіть якщо ви його не використовуєте (проблема Tree-shaking). Це робить ваш фінальний JS-файл важчим.


3. Сучасна Альтернатива: as const Object

Це підхід, який використовують професійні команди. Ми беремо звичайний об'єкт і "заморожуємо" його типи.

const UserRole = {
    Admin: 'admin',
    Editor: 'editor',
    User: 'user',
} as const // 👈 Магія тут!

Що робить as const?

  1. Робить усі поля readonly.
  2. Перетворює значення з string на Literal Types ("admin", "editor", "user").

🧐 Розбір магії: keyof typeof

Ця конструкція часто лякає новачків. Давайте розберемо її по кроках:

type UserRole = (typeof UserRole)[keyof typeof UserRole]
  1. typeof UserRole: Отримує тип самого об'єкта.
    • Результат: { readonly Admin: "admin", readonly Editor: "editor", ... }
  2. keyof typeof UserRole: Бере всі ключі цього типу.
    • Результат: "Admin" | "Editor" | "User"
  3. Object[Keys]: Каже: "Дай мені типи всіх значень, які доступні за цими ключами".
    • Фінальний результат: "admin" | "editor" | "user"

Тепер у нас є тип, який поводиться як enum, але є чистим JS і ідеально підтримує Tree-shaking.


Більше прикладів as const

Приклад 1: Ендпоінти API

const API_ENDPOINTS = {
    GET_USERS: '/api/v1/users',
    GET_POSTS: '/api/v1/posts',
    LOGIN: '/api/auth/login',
} as const

type ApiEndpoint = (typeof API_ENDPOINTS)[keyof typeof API_ENDPOINTS]

function fetchData(url: ApiEndpoint) {
    /* ... */
}

fetchData(API_ENDPOINTS.LOGIN) // ✅
fetchData('/api/hack') // 🛑 Помилка!

Приклад 2: HTTP Статуси

const HttpStatus = {
    Ok: 200,
    NotFound: 404,
    ServerError: 500,
} as const

type HttpStatus = (typeof HttpStatus)[keyof typeof HttpStatus] // 200 | 404 | 500

Що ж обрати?

Функціяenumas const Object
Чистий JS❌ Ні (додає код)✅ Так
Безпека чисел❌ Погана✅ Відмінна
Tree-shaking❌ Важко✅ Легко
Складність синтаксису✅ Просто⚠️ Потрібно звикнути

Порада: Якщо ви пишете сучасний веб-додаток — використовуйте as const об'єкти. Це стандарт галузі. Використовуйте enum тільки якщо працюєте в старому проекті, де вони вже всюди.


8. Type Guards (Вартові Типів)

Як навчити TS розуміти нашу логіку перевірок? Ми використовували typeof та instanceof. Але для складних об'єктів цього мало.

User-Defined Type Guards (is)

Це функція, яка повертає boolean, але має особливий тип повернення arg is Type.

interface Fish {
    swim: () => void
}
interface Bird {
    fly: () => void
}

// Функція-предикат
function isFish(pet: Fish | Bird): pet is Fish {
    // Перевіряємо наявність унікального методу
    return (pet as Fish).swim !== undefined
}

const myPet = getPet()

if (isFish(myPet)) {
    myPet.swim() // ✅ Тут TS знає: це Fish
} else {
    myPet.fly() // ✅ Тут TS знає: це точно Bird (бо не Fish)
}

Assertion Functions (asserts)

Це функції, які кидають помилку, якщо умова не виконується. Корисно для тестів або валідації вхідних даних.

function assertIsString(val: any): asserts val is string {
    if (typeof val !== 'string') {
        throw new AssertionError('Not a string!')
    }
}

const inputValue: unkown = 'hello'
assertIsString(inputValue)
// Після цього рядка inputValue вважається string
console.log(inputValue.toUpperCase())

9. Рекурсивні Типи

Можна визначати типи, які посилаються самі на себе. Класичний приклад: JSON.

type JSONValue =
    | string
    | number
    | boolean
    | null
    | JSONValue[] // Рекурсія: масив JSONValue
    | { [key: string]: JSONValue } // Рекурсія: об'єкт з JSONValue

Це дозволяє описувати дерева будь-якої глибини.


10. Mapped Types (Типи, що відображаються)

Уявіть, що типи — це дані. Тоді Mapped Types — це функція Array.prototype.map(), але для об'єктних типів. Вони дозволяють створити новий тип, трансформуючи властивості існуючого.

Це один з найпотужніших інструментів для дотримання принципу DRY (Don't Repeat Yourself) у типах.

Аналогія: Цикл по об'єкту

Якщо у JavaScript ми можемо пройтися циклом по ключах об'єкта, то у TypeScript Mapped Types роблять те саме на рівні компіляції:

const user = { name: 'Alice', age: 25 }
// JS: Перетворити всі значення на рядки
const stringifiedUser = Object.keys(user).reduce((acc, key) => {
    acc[key] = String(user[key])
    return acc
}, {})

Розбір Синтаксису по кісточках

Давайте розберемо цей "страшний" рядок: [Key in keyof T]: T[Key].

interface User {
    id: number
    name: string
}

type MappedUser = {
    [K in keyof User]: User[K]
}
  1. keyof User: Це оператор, який повертає Union тип усіх ключів.
    • Результат: "id" | "name".
  2. in: Аналогічно до циклу for...in. Він каже TypeScript: "пройдися по кожному значенню з цього Union'у".
  3. [K in ...]: Тут створюється змінна циклу K. На кожній ітерації K буде приймати нове значення ключа.
  4. User[K] (Indexed Access Type): Ми кажемо: "візьми тип, який відповідає ключу K в оригінальному інтерфейсі User".
    • Коли K = "id", User["id"] поверне number.
    • Коли K = "name", User["name"] поверне string.

Результат: MappedUser буде ідентичним User. Поки що ми нічого не змінили, лише скопіювали. Справжня магія починається з модифікаторів.


Модифікатори: Зміна властивостей на льоту

Ми можемо додавати або видаляти опціональність (?) та незмінність (readonly).

1. Робимо все опціональним (Partial)

Додаємо ? після закриваючої дужки ключа:

type PartialUser = {
    [K in keyof User]?: User[K] // Додає ? до кожного ключа
}
// Результат: { id?: number; name?: string; }

2. Робимо все незмінним (Readonly)

Додаємо префікс readonly:

type ReadonlyUser = {
    readonly [K in keyof User]: User[K]
}

3. Видалення модифікаторів (- та +)

Ми можемо не тільки додавати, а й примусово видаляти префікси. Найчастіше використовується -? (зробити обов'язковим) та -readonly (зробити мутабельним).

interface PartialSettings {
    theme?: string
    fontSize?: number
}

// Перетворюємо всі опціональні поля на обов'язкові
type RequiredSettings = {
    [K in keyof PartialSettings]-?: PartialSettings[K]
}
// Результат: { theme: string; fontSize: number; }

Практичний приклад: Feature Flags Toggle

Уявіть, що у вас є конфігурація фіч, де значення — це або boolean, або складна конфігурація. Вам потрібен тип, де всі ці ключі існують, але кожне значення перетворене на просту функцію "Toggle".

interface Features {
    darkMode: boolean
    betaNav: { items: string[] }
    experimentalEditor: boolean
}

type FeatureToggles = {
    [K in keyof Features]: () => void
}

const toggles: FeatureToggles = {
    darkMode: () => console.log('Dark Mode toggled'),
    betaNav: () => console.log('Beta Nav toggled'),
    experimentalEditor: () => console.log('Editor toggled'),
}

Mapped Types — це фундамент для вбудованих утиліт TypeScript, таких як Partial<T>, Required<T>, Pick<T, K> та Record<K, T>. Ми розберемо їх у наступному розділі про Generics.



11. Магія keyof та typeof

Іноді нам треба отримати тип ключів об'єкта.

interface User {
    id: number
    name: string
}

// "id" | "name"
type UserKeys = keyof User

А якщо у нас є значення (об'єкт), а ми хочемо його тип? typeof.

const config = {
    apiKey: '123',
    timeout: 5000,
}

type Config = typeof config
// { apiKey: string; timeout: number; }

Патерн keyof typeof

Це "супер-комбо", яке розв'язує одну з найпоширеніших проблем: як отримати типи з існуючих JavaScript даних, не дублюючи їх вручну.

У чому проблема?

Уявіть, що у вас є словник конфігурації. Ви хочете написати функцію, яка приймає лише ключі цього словника.

const ThemeColors = {
    primary: '#0070f3',
    secondary: '#1e1e1e',
    success: '#0070f3',
} as const

Якщо ви напишете function getColor(name: string), ви втратите типобезпеку (можна передати будь-який рядок). Якщо ви створите type ColorName = "primary" | "secondary" | "success", вам доведеться оновлювати цей тип щоразу, коли ви додаєте колір у об'єкт.

Розв'язання: keyof typeof

Давайте розберемо, як це працює, крок за кроком:

type ColorName = keyof typeof ThemeColors
  1. typeof ThemeColors: Оператор typeof у контексті типів каже TypeScript: "Подивися на цю JavaScript-змінну і згенеруй тип, який точно описує її структуру".
    • Отриманий тип: { readonly primary: "#0070f3", readonly secondary: "#1e1e1e", ... }
  2. keyof ...: Тепер ми беремо отриманий тип і кажемо: "Дай мені Union всіх ключів цього типу".
    • Отриманий результат: "primary" | "secondary" | "success"

Чому це важливо?

Тепер ваше джерело істини (Single Source of Truth) — це сам об'єкт ThemeColors. Додайте новий колір у об'єкт — і тип ColorName оновиться автоматично!


Практичний приклад: Безпечний доступ до конфігурації

Ви хочете написати функцію, яка дістає значення з конфігурації за ключем.

const AppConfig = {
    apiUrl: 'https://api.example.com',
    retryCount: 3,
    debugMode: true,
} as const

// 1. Створюємо тип ключів
type ConfigKey = keyof typeof AppConfig

// 2. Використовуємо його у функції
function getConfigValue(key: ConfigKey) {
    return AppConfig[key]
}

getConfigValue('apiUrl') // ✅ Працює, повертає рядок
getConfigValue('retryCount') // ✅ Працює, повертає 3
// getConfigValue("port"); // 🛑 Помилка! Ключа "port" не існує в AppConfig
Пам'ятайте про as const! Без as const TypeScript сприймає значення об'єкта як загальні типи (string, number). as const робить їх Literals, а сам об'єкт — readonly, що є критичним для точної генерації типів.::

12. Поліморфізм та Generic Interfaces

Уявіть, що ви пишете компонент List, який може рендерити будь-що: користувачів, товари, котів. Вам потрібен Generic Interface.
// T — це змінна типу (можна назвати як завгодно, але T стандарт)
interface ListProps<T> {
    items: T[]
    renderItem: (item: T) => string
}

// Використання
const users: ListProps<User> = {
    items: [{ id: 1, name: 'Alice' }],
    renderItem: (u) => u.name,
}

const products: ListProps<Product> = {
    items: [{ name: 'iPhone' }],
    renderItem: (p) => p.name,
}
Ми поговоримо про Generics детально в наступному розділі, але в контексті інтерфейсів це база.

13. Advanced Template Literal Types

Ми бачили прості приклади. Тепер хардкор. Як типізувати обробники подій?
type EventName = 'click' | 'hover' | 'focus'

// Генерований тип: "onClick" | "onHover" | "onFocus"
// Capitalize - вбудований утилітний тип (Intrinsic String Manipulation)
type HandlerName = `on${Capitalize<EventName>}`

const handlers: Record<HandlerName, () => void> = {
    onClick: () => {},
    onHover: () => {},
    onFocus: () => {},
}
Або аналізатор CSS margin'ів.
type Size = 'sm' | 'md' | 'lg'
type MarginClass = `m-${Size}` // "m-sm" | "m-md" | "m-lg"
type PaddingClass = `p-${Size}` // "p-sm" | "p-md" | "p-lg"
Це дозволяє створювати типізовані дизайн-системи, де неможливо використати неіснуючий клас.

14. Type Guards у масивах (filter)

Це класична проблема. Ви фільтруєте масив, щоб прибрати null, але TS все одно думає, що там може бути null.
const values = ['A', null, 'B', undefined, 'C']

// Тип result: (string | null | undefined)[] 😱
// TS не аналізує логіку колбека filter так глибоко
const result = values.filter((v) => v !== null && v !== undefined)

// result[0].toUpperCase(); // Error: Object is possibly 'null'.
Рішення: Type Guard Function.
// Функція, яка каже: "Якщо це true, то val точно типу T"
function isDefined<T>(val: T | null | undefined): val is T {
    return val !== null && val !== undefined
}

// Тепер result: string[] ✅
const safeResult = values.filter(isDefined)
Це маст-хев утиліта для будь-якого проекту.

15. Discriminated Unions: Async State

Давайте змоделюємо стан HTTP запиту. Це найкращий приклад сили Union Types.
// 1. Loading
type Loading = { status: 'loading' }

// 2. Success
type Success<T> = { status: 'success'; data: T }

// 3. Error
type Failed = { status: 'error'; error: Error }

// 4. Union
type RequestState<T> = Loading | Success<T> | Failed

// Компонент (умовний)
function UserProfile({ state }: { state: RequestState<User> }) {
    // 🛑 Не можна: state.data

    switch (state.status) {
        case 'loading':
            return 'Spinner...'
        case 'error':
            return `Error: ${state.error.message}` // TS знає, що тут є error
        case 'success':
            return `User: ${state.data.username}` // TS знає, що тут є data
    }
}
Можливості зробити баг (наприклад, показати спінер І дані одночасно) немає. Стан або такий, або такий.

16. Анти-патерни: IWrapper, IData

Префікс "I"

У C# прийнято називати інтерфейси IUser, IService. У TypeScript це анти-патерн. Чому? Тому що в TS важлива структура, а не ім'я. Ви (як споживач) не повинні знати, чи це interface, чи type, чи class.
// ❌ Погано
interface IUser {
    name: string
}

// ✅ Добре
interface User {
    name: string
}

God Objects (Божественні об'єкти)

Не намагайтеся запхнути все в один інтерфейс. Розбивайте на менші частини.
// ❌ Погано
interface User {
    username: string
    jwtToken: string
    shippingAddress: string
    billingAddress: string
    isModalOpen: boolean // UI стан в доменній моделі
}

// ✅ Добре
interface User {
    username: string
}
interface AuthInfo {
    token: string
}
interface Addresses {
    shipping: string
    billing: string
}

17. Практичне Завдання: Feature Flags System

Вам потрібно реалізувати типізовану систему Feature Flags.

Вимоги

  1. Опишіть типи для можливих значень прапорів:
    • boolean (увімкнено/вимкнено)
    • string (варіант A/B тесту)
    • json (складна конфігурація)
  2. Створіть тип FeatureFlag як Discriminated Union.
    type FeatureFlag = { kind: 'boolean'; value: boolean } | { kind: 'string'; value: 'A' | 'B' }
    // додумайте json
    
  3. Напишіть функцію getFeatureValue(flag: FeatureFlag), яка повертає значення у правильному типі. (Спробуйте використати перевантаження функцій або Generics, але поки можна просто через перевірку kind).

Бонус

Зробіть тип Config, де ключі — це назви фіч, а значення — це FeatureFlag. Але! Зробіть так, щоб не можна було змінювати конфіг (Readonly).

18. Шпаргалка (Cheatsheet)

ЗадачаРішенняПриклад
Об'єднати варіантиUniontype ID = string | number
Об'єднати властивостіIntersectiontype User = Person & Worker
Опціональне поле?age?: number
Тільки для читанняreadonlyreadonly id: number
Динамічні ключіIndex Signature[key: string]: number
Ключі з типуkeyofkeyof User
Тип зі змінноїtypeoftypeof config
Тип із рядкаLiteral Type"success" | "error"
Перевірка типуType Guardarg is User

20. Case Study: Redux Action Types

Давайте подивимося, як Discriminated Unions працюють у реальному Redux (або useReducer).

Проблема

У класичному Redux ми використовували константи-рядки. const LOGIN_SUCCESS = 'LOGIN_SUCCESS'Це не давало нам зв'язку між типом екшена і його пейлоадом (payload).

Рішення

// 1. Описуємо кожен екшен окремо
type LoginStart = { type: 'LOGIN_START' }
type LoginSuccess = { type: 'LOGIN_SUCCESS'; payload: { token: string } }
type LoginFailure = { type: 'LOGIN_FAILURE'; payload: { error: string } }
type Logout = { type: 'LOGOUT' }

// 2. Union всіх можливих екшенів
type AuthActions = LoginStart | LoginSuccess | LoginFailure | Logout

// 3. State
interface AuthState {
    user: { token: string } | null
    isLoading: boolean
    error: string | null
}

// 4. Reducer
function authReducer(state: AuthState, action: AuthActions): AuthState {
    switch (action.type) {
        case 'LOGIN_START':
            return { ...state, isLoading: true, error: null }

        case 'LOGIN_SUCCESS':
            // TS знає: тут action має поле payload з token
            return { ...state, isLoading: false, user: action.payload }

        case 'LOGIN_FAILURE':
            return { ...state, isLoading: false, error: action.payload.error }

        case 'LOGOUT':
            return { ...state, user: null }

        default:
            // Exhaustiveness Check
            const _: never = action
            return state
    }
}
Це ідеальний код. Ви не можете опечататися в action.type. Ви не можете звернутися до payload, якщо його немає.

21. Case Study: Form Validation State

Складні форми — це біль фронтенду. Давайте типізуємо стан поля форми.
type ValidationResult = { isValid: true } | { isValid: false; errors: string[] }

type FormField<T> = {
    value: T
    isDirty: boolean
    isTouched: boolean
    validation: ValidationResult
}

// Використання
const emailField: FormField<string> = {
    value: 'test@',
    isDirty: true,
    isTouched: true,
    validation: { isValid: false, errors: ['Invalid email'] },
}

function renderError(field: FormField<string>) {
    // Якщо валідно - нічого не рендеримо
    if (field.validation.isValid) {
        return null
    }

    // Якщо не валідно - TS знає, що є errors
    return field.validation.errors.join(', ')
}
Зверніть увагу, як ми вклали один Union (ValidationResult) в інший тип. Це композиція типів у дії.

22. Case Study: Polymorphic Component (React)

Ви хочете створити кнопку, яка може бути посиланням (<a>) або кнопкою (<button>).
type ButtonProps = {
    variant: "primary" | "secondary";
    children: string;
} & (
    | { as: "button"; onClick: () => void; href?: never }
    | { as: "a"; href: string; onClick?: never } // Якщо 'a', то onClick заборонено (для прикладу)
);

function Button(props: ButtonProps) {
    if (props.as === "a") {
        return <a href={props.href} className={props.variant}>{props.children}</a>;
    }

    return <button onClick={props.onClick} className={props.variant}>{props.children}</button>;
}

// ✅
// <Button as="button" variant="primary" onClick={() => {}}>Click</Button>

// ✅
// <Button as="a" variant="secondary" href="/home">Link</Button>

// 🛑 Error: Property 'href' does not exist on type '{ as: "button"... }'
// <Button as="button" href="/home">Bad</Button>
Цей патерн дозволяє створювати дуже гнучкі UI кіти.

23. Operator satisfies (TS 4.9+)

Це один з найкорисніших нових операторів. Він дозволяє перевірити, чи об'єкт відповідає типу, АЛЕ при цьому зберегти найбільш специфічний тип самого об'єкта.Без satisfies:
type Color = string | { r: number; g: number; b: number }

const myColor: Color = 'red'
// console.log(myColor.toUpperCase()); // 🛑 Error: Property 'toUpperCase' does not exist on type 'Color'.
З satisfies:
const myColor = 'red' satisfies Color
console.log(myColor.toUpperCase()) // ✅ Працює!
TS перевірив, що "red" підходить під Color, але залишив тип myColor як "red", а не Color. Це дозволяє уникати зайвих звужень типів.

24. Excess Property Checks (Перевірка надлишкових полів)

Чому TypeScript іноді дозволяє зайві поля, а іноді — ні?
interface User {
    name: string
}

// 🛑 Випадок 1: Object Literal (Помилка!)
const u1: User = { name: 'Alice', age: 30 } // Error: Object literal may only specify known properties.

// ✅ Випадок 2: Reference (ОК!)
const tmp = { name: 'Alice', age: 30 }
const u2: User = tmp // Працює через Структурну Типізацію.
Коли ви передаєте об'єктний літерал напряму, TS припускає, що ви допустили опечатку. Коли через посилання — він перевіряє тільки мінімально необхідний набір полів.

25. Практичне Завдання: CRM System

Створіть систему управління клієнтами (CRM).

Вимоги

  1. Опишіть тип Client з полями id, name, email.
  2. Використовуйте Discriminated Union для статусу клієнта:
    • "lead" (тільки email)
    • "active" (має contractId)
    • "inactive" (має reason чому пішов)
  3. Опишіть тип CRMAction для Redux-подібного редюсера (actions: ADD_CLIENT, UPDATE_STATUS, DELETE_CLIENT).

26. Часті питання (FAQ)

Чи можна розширити type?

Так, через Intersection: type NewType = OldType & { newField: string }.

Що краще: type чи interface?

Для об'єктів у 90% випадків interface. Для всього іншого — type.

Чому readonly дозволяє змінювати вкладені об'єкти?

Бо це Shallow Readonly. TS не вміє автоматично робити Deep Readonly без спеціальних утиліт, бо це б дуже сповільнювало компіляцію великих проектів.

27. Резюме Розділу

Ми навчилися говорити мовою типів. Тепер ви можете не просто перевіряти помилки, а проектувати систему так, щоб помилки були неможливі.
  1. Interfaces для публічних контрактів.
  2. Types для Union та складних конструкцій.
  3. Discriminated Unions для управління станом.
  4. Literal Types для точності.
У наступному розділі ми зануримося у світ, де типи стають змінними. Generics. Ми напишемо функції, які працюють з будь-якими даними, зберігаючи типобезпеку.

Далі: Generics та Utility Types

Вчимося писати гнучкий код.
Copyright © 2026