Майстерність Моделювання Даних: Інтерфейси та Просунуті Типи
Розділ 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 Settings двічі — отримаєте помилку Duplicate identifier.Що обрати?
| Характеристика | Interface | Type 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'
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
}
}
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
Щоб створити Дискриміноване Об'єднання, потрібні три інгредієнти:
- Типи, які мають спільну назву властивості (напркилад,
type,statusабоkind). - Ця властивість має бути Literal Type (конкретне значення).
- 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(', ')} ✅`
}
}
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?
- Робить усі поля
readonly. - Перетворює значення з
stringна Literal Types ("admin","editor","user").
🧐 Розбір магії: keyof typeof
Ця конструкція часто лякає новачків. Давайте розберемо її по кроках:
type UserRole = (typeof UserRole)[keyof typeof UserRole]
typeof UserRole: Отримує тип самого об'єкта.- Результат:
{ readonly Admin: "admin", readonly Editor: "editor", ... }
- Результат:
keyof typeof UserRole: Бере всі ключі цього типу.- Результат:
"Admin" | "Editor" | "User"
- Результат:
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
Що ж обрати?
| Функція | enum | as 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]
}
keyof User: Це оператор, який повертає Union тип усіх ключів.- Результат:
"id" | "name".
- Результат:
in: Аналогічно до циклуfor...in. Він каже TypeScript: "пройдися по кожному значенню з цього Union'у".[K in ...]: Тут створюється змінна циклуK. На кожній ітераціїKбуде приймати нове значення ключа.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
typeof ThemeColors: Операторtypeofу контексті типів каже TypeScript: "Подивися на цю JavaScript-змінну і згенеруй тип, який точно описує її структуру".- Отриманий тип:
{ readonly primary: "#0070f3", readonly secondary: "#1e1e1e", ... }
- Отриманий тип:
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,
}
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: () => {},
}
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'.
// Функція, яка каже: "Якщо це 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.Вимоги
- Опишіть типи для можливих значень прапорів:
boolean(увімкнено/вимкнено)string(варіант A/B тесту)json(складна конфігурація)
- Створіть тип
FeatureFlagяк Discriminated Union.type FeatureFlag = { kind: 'boolean'; value: boolean } | { kind: 'string'; value: 'A' | 'B' } // додумайте json - Напишіть функцію
getFeatureValue(flag: FeatureFlag), яка повертає значення у правильному типі. (Спробуйте використати перевантаження функцій або Generics, але поки можна просто через перевіркуkind).
Бонус
Зробіть типConfig, де ключі — це назви фіч, а значення — це FeatureFlag.
Але! Зробіть так, щоб не можна було змінювати конфіг (Readonly).18. Шпаргалка (Cheatsheet)
| Задача | Рішення | Приклад |
|---|---|---|
| Об'єднати варіанти | Union | type ID = string | number |
| Об'єднати властивості | Intersection | type User = Person & Worker |
| Опціональне поле | ? | age?: number |
| Тільки для читання | readonly | readonly id: number |
| Динамічні ключі | Index Signature | [key: string]: number |
| Ключі з типу | keyof | keyof User |
| Тип зі змінної | typeof | typeof config |
| Тип із рядка | Literal Type | "success" | "error" |
| Перевірка типу | Type Guard | arg 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(', ')
}
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>
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()) // ✅ Працює!
"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 // Працює через Структурну Типізацію.
25. Практичне Завдання: CRM System
Створіть систему управління клієнтами (CRM).Вимоги
- Опишіть тип
Clientз полямиid,name,email. - Використовуйте Discriminated Union для статусу клієнта:
"lead"(тільки email)"active"(маєcontractId)"inactive"(маєreasonчому пішов)
- Опишіть тип
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. Резюме Розділу
Ми навчилися говорити мовою типів. Тепер ви можете не просто перевіряти помилки, а проектувати систему так, щоб помилки були неможливі.- Interfaces для публічних контрактів.
- Types для Union та складних конструкцій.
- Discriminated Unions для управління станом.
- Literal Types для точності.