Розширені засоби мови TypeScript

Utility Types

Вбудовані utility types: Partial, Required, Pick, Omit, Record та інші для трансформації типів

Utility Types

🎯 Мета розділу

  • Опанувати вбудовані utility types TypeScript для трансформації існуючих типів.
  • Зрозуміти, як Partial, Required, Readonly модифікують модифікатори властивостей.
  • Навчитися використовувати Pick, Omit, Extract, Exclude для фільтрації типів.
  • Застосовувати Record, ReturnType, Parameters для створення нових типів на основі існуючих.
  • Усвідомити внутрішню реалізацію utility types через mapped та conditional types.
  • Створювати типобезпечні API та функції без дублювання визначень типів.

🔑 Ключові терміни

  • Utility Type: вбудований generic тип TypeScript, що трансформує інший тип за певним правилом.
  • Type Transformation: процес створення нового типу на основі існуючого шляхом модифікації його структури.
  • Mapped Type: тип, що ітерує по властивостях іншого типу та створює нову структуру (основа utility types).
  • Conditional Type: тип, що обирає одну з альтернатив залежно від умови (основа Extract/Exclude).
  • Type Inference in Conditionals: автоматичне виведення типу з conditional type через ключове слово infer.

Проблема дублювання типів та мотивація для utility types

У реальних застосунках часто виникає ситуація, коли потрібен варіант існуючого типу з невеликими змінами. Розглянемо типову задачу: створення користувача та його оновлення.

Наївний підхід: дублювання визначень

interface User {
  id: string;
  email: string;
  name: string;
  age: number;
  isActive: boolean;
  createdAt: Date;
}

// Для створення користувача потрібні всі поля крім id та createdAt (вони генеруються сервером)
interface CreateUserDto {
  email: string;
  name: string;
  age: number;
  isActive: boolean;
}

// Для оновлення користувача всі поля опціональні (оновлюємо лише те, що змінилося)
interface UpdateUserDto {
  email?: string;
  name?: string;
  age?: number;
  isActive?: boolean;
}

function createUser(data: CreateUserDto): User {
  return {
    id: generateId(),
    createdAt: new Date(),
    ...data
  };
}

function updateUser(id: string, data: UpdateUserDto): User {
  const user = findUserById(id);
  return { ...user, ...data };
}

Проблема: Ми продублювали визначення полів тричі! Якщо додамо нове поле у User (наприклад, phone?: string), доведеться вручну додавати його у CreateUserDto та UpdateUserDto. Це порушує принцип DRY та призводить до помилок при рефакторингу.

Рішення через utility types

TypeScript надає utility types — набір вбудованих generic типів, що дозволяють створювати похідні типи автоматично:

interface User {
  id: string;
  email: string;
  name: string;
  age: number;
  isActive: boolean;
  createdAt: Date;
}

// Omit<User, 'id' | 'createdAt'> — User без полів id та createdAt
type CreateUserDto = Omit<User, 'id' | 'createdAt'>;

// Partial<Omit<User, 'id' | 'createdAt'>> — всі поля опціональні
type UpdateUserDto = Partial<Omit<User, 'id' | 'createdAt'>>;

function createUser(data: CreateUserDto): User {
  return {
    id: generateId(),
    createdAt: new Date(),
    ...data
  };
}

function updateUser(id: string, data: UpdateUserDto): User {
  const user = findUserById(id);
  return { ...user, ...data };
}

Тепер типи CreateUserDto та UpdateUserDto автоматично синхронізуються з User. Додавання нового поля у User автоматично відобразиться у похідних типах — не потрібно нічого міняти вручну.

Utility types — це "функції на рівні типів": вони приймають тип як вхід та повертають трансформований тип як результат. Partial<T> бере тип T та повертає новий тип, де всі властивості стали опціональними. Це аналог функцій вищого порядку, але для системи типів.

Partial: всі властивості стають опціональними

Partial<T> створює новий тип, де всі властивості типу T стають опціональними (додається модифікатор ?).

Синтаксис та приклад

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
}

type PartialProduct = Partial<Product>;

// Еквівалентно:
// type PartialProduct = {
//   id?: string;
//   name?: string;
//   price?: number;
//   description?: string;
// }

function updateProduct(id: string, updates: Partial<Product>): Product {
  const product = findProductById(id);
  return { ...product, ...updates };  // об'єднуємо існуючий продукт з оновленнями
}

// Можна оновити лише одне поле:
updateProduct('prod-1', { price: 2500 });

// Або кілька полів:
updateProduct('prod-2', { name: 'Новий ноутбук', description: 'Покращений опис' });

// Або взагалі нічого:
updateProduct('prod-3', {});

Типовий use case: Функції оновлення (update, patch), де користувач передає лише ті поля, що потрібно змінити, а не повний об'єкт.

Внутрішня реалізація Partial

Розуміння того, як реалізовано Partial<T>, допомагає усвідомити механіку utility types:

type Partial<T> = {
  [P in keyof T]?: T[P];
};

Це mapped type (відображаючий тип):

  • keyof T — отримуємо union всіх ключів типу T ("id" | "name" | "price" | "description" для Product)
  • [P in keyof T] — ітеруємося по кожному ключу P
  • ? — додаємо модифікатор опціональності
  • T[P] — зберігаємо оригінальний тип властивості

Результат: для кожної властивості з T створюється опціональна властивість з тим самим типом значення.

Обмеження Partial: Він робить опціональними лише властивості першого рівня. Вкладені об'єкти залишаються обов'язковими:
interface User {
  id: string;
  profile: {
    firstName: string;
    lastName: string;
  };
}

type PartialUser = Partial<User>;

const user: PartialUser = {
  profile: {
    firstName: 'Олександр'
    // ❌ Error: Property 'lastName' is missing
  }
};
Для глибокої рекурсивної опціональності потрібен кастомний тип (DeepPartial), про який поговоримо пізніше.

Required: всі властивості стають обов'язковими

Required<T> є протилежністю Partial<T> — він створює тип, де всі опціональні властивості стають обов'язковими (видаляється модифікатор ?).

Синтаксис та приклад

interface UserPreferences {
  theme?: 'light' | 'dark';
  language?: 'uk' | 'en' | 'pl';
  notifications?: boolean;
  emailUpdates?: boolean;
}

type RequiredPreferences = Required<UserPreferences>;

// Еквівалентно:
// type RequiredPreferences = {
//   theme: 'light' | 'dark';
//   language: 'uk' | 'en' | 'pl';
//   notifications: boolean;
//   emailUpdates: boolean;
// }

function applyDefaultPreferences(): Required<UserPreferences> {
  return {
    theme: 'light',
    language: 'uk',
    notifications: true,
    emailUpdates: false
  };
}

// ❌ Error: всі поля обов'язкові
// const prefs: Required<UserPreferences> = { theme: 'dark' };

Типовий use case: Функції ініціалізації або валідації, де потрібно гарантувати, що всі поля заповнені (наприклад, після застосування значень за замовчуванням).

Внутрішня реалізація Required

type Required<T> = {
  [P in keyof T]-?: T[P];
};

Відмінність від Partial лише в -? замість ?:

  • -? — видаляє модифікатор опціональності (якщо він був)
  • Якщо властивість була обов'язковою, вона залишається обов'язковою
  • Якщо властивість була опціональною, вона стає обов'язковою

Readonly: всі властивості стають незмінними

Readonly<T> створює тип, де всі властивості позначені як readonly — їх неможливо змінити після створення об'єкта.

Синтаксис та приклад

interface Config {
  apiUrl: string;
  timeout: number;
  retries: number;
}

const config: Readonly<Config> = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3
};

console.log(config.apiUrl);  // ✅ Читання дозволене

// ❌ Error: Cannot assign to 'apiUrl' because it is a read-only property
// config.apiUrl = 'https://api.other.com';

// ❌ Error: Cannot assign to 'timeout' because it is a read-only property
// config.timeout = 10000;

Типовий use case: Конфігурації, константи, іммутабельні дані, об'єкти стану, що не повинні змінюватися після ініціалізації.

Внутрішня реалізація Readonly

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

Механіка аналогічна Partial, але додається модифікатор readonly замість ?.

Readonly на рівні TypeScript vs глибока іммутабельність:Readonly<T> працює лише на першому рівні вкладеності. Якщо властивість є об'єктом або масивом, її внутрішні елементи можна змінювати:
interface User {
  id: string;
  tags: string[];
}

const user: Readonly<User> = {
  id: 'user-1',
  tags: ['admin', 'moderator']
};

// ❌ Error: Cannot assign to 'id'
// user.id = 'user-2';

// ✅ OK: масив tags readonly, але його елементи — ні
user.tags.push('editor');
console.log(user.tags);  // ['admin', 'moderator', 'editor']
Для глибокої іммутабельності потрібен DeepReadonly (кастомний тип або бібліотека ts-essentials).

Pick<T, K>: вибір підмножини властивостей

Pick<T, K> створює новий тип, що містить лише вказані властивості з типу T. K — union літеральних типів ключів, які потрібно залишити.

Синтаксис та приклад

interface User {
  id: string;
  email: string;
  password: string;
  firstName: string;
  lastName: string;
  age: number;
  isActive: boolean;
  createdAt: Date;
  updatedAt: Date;
}

// Для відповіді API повертаємо лише публічні дані
type UserPublicData = Pick<User, 'id' | 'email' | 'firstName' | 'lastName'>;

// Еквівалентно:
// type UserPublicData = {
//   id: string;
//   email: string;
//   firstName: string;
//   lastName: string;
// }

function getUserPublicProfile(userId: string): UserPublicData {
  const user = findUserById(userId);
  return {
    id: user.id,
    email: user.email,
    firstName: user.firstName,
    lastName: user.lastName
  };
}

// Для форми логіну потрібні лише email та password
type LoginCredentials = Pick<User, 'email' | 'password'>;

function login(credentials: LoginCredentials): boolean {
  // перевірка email та password
  return true;
}

Типовий use case:

  • DTO для API (вибір публічних полів)
  • Форми (вибір полів для введення)
  • Проєкції даних (вибір потрібних колонок з таблиці)

Внутрішня реалізація Pick

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

Механіка:

  • K extends keyof T — constraint гарантує, що K містить лише існуючі ключі з T
  • [P in K] — ітеруємось лише по ключах з K (не по всіх ключах T)
  • T[P] — беремо тип властивості з оригінального типу T
Pick vs інтерфейс окремо: Використовуйте Pick, коли похідний тип семантично пов'язаний з батьківським і має змінюватися разом з ним. Якщо це незалежна сутність, краще визначити окремий інтерфейс:
// ✅ Добре: LoginCredentials — підмножина User
type LoginCredentials = Pick<User, 'email' | 'password'>;

// ❌ Не дуже: UserDTO може мати власну логіку, що не прив'язана до User
// Краще визначити окремо:
interface UserDTO {
  id: string;
  email: string;
  displayName: string;  // може відрізнятися від User.firstName
}

Omit<T, K>: виключення властивостей

Omit<T, K> є протилежністю Pick — він створює тип, що містить всі властивості T, крім вказаних у K.

Синтаксис та приклад

interface User {
  id: string;
  email: string;
  password: string;
  firstName: string;
  lastName: string;
  createdAt: Date;
}

// Для створення користувача виключаємо id та createdAt (генеруються автоматично)
type CreateUserDto = Omit<User, 'id' | 'createdAt'>;

// Еквівалентно:
// type CreateUserDto = {
//   email: string;
//   password: string;
//   firstName: string;
//   lastName: string;
// }

function createUser(data: CreateUserDto): User {
  return {
    id: generateId(),
    createdAt: new Date(),
    ...data
  };
}

// Для відповіді API виключаємо чутливі дані
type UserResponse = Omit<User, 'password'>;

function getUserById(id: string): UserResponse {
  const user = findUserById(id);
  const { password, ...safeData } = user;  // видаляємо password
  return safeData;
}

Типовий use case:

  • DTO для створення сутностей (виключення автогенерованих полів)
  • Відповіді API (виключення чутливих даних)
  • Модифікації існуючих типів (видалення непотрібних властивостей)

Внутрішня реалізація Omit

type Omit<T, K extends string | number | symbol> = Pick<T, Exclude<keyof T, K>>;

Цікаво, що Omit реалізовано через композицію двох utility types:

  1. Exclude<keyof T, K> — з усіх ключів T виключаємо ключі з K
  2. Pick<T, ...> — вибираємо з T лише ті ключі, що залишилися після виключення

Наприклад, для Omit<User, 'id' | 'password'>:

  • keyof User → "id" | "email" | "password" | "firstName" | ...
  • Exclude<keyof User, 'id' | 'password'> → "email" | "firstName" | ...
  • Pick<User, "email" | "firstName" | ...> → фінальний тип
Omit vs Pick: Вибір між ними залежить від того, чого менше — полів, що потрібно залишити (використовуйте Pick) чи виключити (використовуйте Omit):
// Якщо потрібно 2 з 10 полів — Pick компактніший
type LoginData = Pick<User, 'email' | 'password'>;

// Якщо потрібно виключити 2 з 10 полів — Omit компактніший  
type UserPublic = Omit<User, 'password' | 'createdAt'>;

Record<K, T>: створення типу з фіксованими ключами

Record<K, T> створює об'єктний тип, де ключі мають тип K, а значення — тип T. Це корисно для створення словників (dictionaries) або мапінгів з відомими ключами.

Синтаксис та приклад

type Role = 'admin' | 'moderator' | 'user';

// Створюємо об'єкт з правами доступу для кожної ролі
type Permissions = Record<Role, string[]>;

// Еквівалентно:
// type Permissions = {
//   admin: string[];
//   moderator: string[];
//   user: string[];
// }

const permissions: Permissions = {
  admin: ['read', 'write', 'delete', 'manage-users'],
  moderator: ['read', 'write', 'delete'],
  user: ['read']
};

function checkPermission(role: Role, action: string): boolean {
  return permissions[role].includes(action);
}

console.log(checkPermission('admin', 'delete'));     // true
console.log(checkPermission('user', 'delete'));      // false

Переваги Record над index signature:

// ❌ Index signature — не гарантує наявність всіх ключів
interface PermissionsLoose {
  [key: string]: string[];
}

const loosePerms: PermissionsLoose = {
  admin: ['read', 'write']
  // moderator та user відсутні — помилки немає
};

// ✅ Record — компілятор вимагає ВСІ ключі з Role
const strictPerms: Record<Role, string[]> = {
  admin: ['read', 'write'],
  moderator: ['read']
  // ❌ Error: Property 'user' is missing
};

Практичний приклад: конфігурація середовищ

type Environment = 'development' | 'staging' | 'production';

interface DatabaseConfig {
  host: string;
  port: number;
  database: string;
}

const dbConfigs: Record<Environment, DatabaseConfig> = {
  development: {
    host: 'localhost',
    port: 5432,
    database: 'myapp_dev'
  },
  staging: {
    host: 'staging-db.example.com',
    port: 5432,
    database: 'myapp_staging'
  },
  production: {
    host: 'prod-db.example.com',
    port: 5432,
    database: 'myapp_prod'
  }
};

function getDbConfig(env: Environment): DatabaseConfig {
  return dbConfigs[env];
}

Внутрішня реалізація Record

type Record<K extends string | number | symbol, T> = {
  [P in K]: T;
};

Простий mapped type: для кожного ключа з K створюємо властивість з типом T.

Record для enum-подібних структур:Record ідеально підходить для створення типобезпечних мапінгів на основі union типів або enum:
enum HttpMethod {
  GET = 'GET',
  POST = 'POST',
  PUT = 'PUT',
  DELETE = 'DELETE'
}

type RouteHandler = (req: Request, res: Response) => void;

const handlers: Record<HttpMethod, RouteHandler> = {
  GET: (req, res) => { /* ... */ },
  POST: (req, res) => { /* ... */ },
  PUT: (req, res) => { /* ... */ },
  DELETE: (req, res) => { /* ... */ }
};
Якщо додамо новий метод у HttpMethod, компілятор вимагатиме додати відповідний handler.

Extract<T, U> та Exclude<T, U>: фільтрація union типів

Ці два utility types працюють з union типами та дозволяють фільтрувати їх членів.

Extract<T, U>: залишити тільки ті типи з T, що присутні в U

Extract<T, U> повертає підтип T, що можна присвоїти U (перетин union типів).

type AllEvents = 
  | { type: 'click'; x: number; y: number }
  | { type: 'keypress'; key: string }
  | { type: 'scroll'; scrollY: number }
  | { type: 'resize'; width: number; height: number };

// Залишаємо лише події з type 'click' або 'keypress'
type UserInputEvents = Extract<AllEvents, { type: 'click' | 'keypress' }>;

// Результат:
// type UserInputEvents = 
//   | { type: 'click'; x: number; y: number }
//   | { type: 'keypress'; key: string }

function handleUserInput(event: UserInputEvents): void {
  if (event.type === 'click') {
    console.log(`Клік на координатах (${event.x}, ${event.y})`);
  } else {
    console.log(`Натиснуто клавішу: ${event.key}`);
  }
}

Простіший приклад з примітивами:

type Mixed = string | number | boolean | null;

type StringOrNumber = Extract<Mixed, string | number>;
// Результат: string | number

type OnlyString = Extract<Mixed, string>;
// Результат: string

Exclude<T, U>: виключити типи з T, що присутні в U

Exclude<T, U> є протилежністю Extract — повертає підтип T, що НЕ можна присвоїти U.

type AllEvents = 
  | { type: 'click'; x: number; y: number }
  | { type: 'keypress'; key: string }
  | { type: 'scroll'; scrollY: number }
  | { type: 'resize'; width: number; height: number };

// Виключаємо події з type 'scroll' або 'resize'
type UserInputEvents = Exclude<AllEvents, { type: 'scroll' | 'resize' }>;

// Результат: той самий, що й Extract у попередньому прикладі

Простіший приклад:

type Mixed = string | number | boolean | null;

type WithoutNull = Exclude<Mixed, null>;
// Результат: string | number | boolean

type OnlyPrimitives = Exclude<Mixed, null | undefined>;
// Результат: string | number | boolean

Внутрішня реалізація через conditional types

type Extract<T, U> = T extends U ? T : never;
type Exclude<T, U> = T extends U ? never : T;

Це conditional types (умовні типи, детально у наступному розділі). Механіка:

  • TypeScript розподіляє (distribute) union type T на окремі члени
  • Для кожного члена перевіряє умову T extends U
  • Залежно від результату обирає T або never
  • never — це "порожній тип", що зникає з union

Приклад роботи:

type Result = Exclude<'a' | 'b' | 'c', 'b'>;

// Крок 1: розподіл union
// Exclude<'a', 'b'> | Exclude<'b', 'b'> | Exclude<'c', 'b'>

// Крок 2: обчислення кожного
// 'a' extends 'b' ? never : 'a'  →  'a'
// 'b' extends 'b' ? never : 'b'  →  never
// 'c' extends 'b' ? never : 'c'  →  'c'

// Крок 3: об'єднання
// 'a' | never | 'c'  →  'a' | 'c'  (never зникає)

ReturnType: отримання типу повернення функції

ReturnType<T> витягує тип повернення з функціонального типу T. Це дозволяє уникнути дублювання визначення типу результату.

Синтаксис та приклад

function getUserData(id: string) {
  return {
    id,
    name: 'Олександр Коваленко',
    email: 'oleksandr@example.com',
    roles: ['user', 'moderator'] as const,
    isActive: true
  };
}

// Замість дублювання типу повернення:
// interface UserData { id: string; name: string; ... }

// Отримуємо тип автоматично з функції
type UserData = ReturnType<typeof getUserData>;

// Результат:
// type UserData = {
//   id: string;
//   name: string;
//   email: string;
//   roles: readonly ["user", "moderator"];
//   isActive: boolean;
// }

function processUser(user: UserData): void {
  console.log(`Обробка користувача ${user.name}`);
  console.log(`Ролі: ${user.roles.join(', ')}`);
}

const userData = getUserData('user-123');
processUser(userData);  // ✅ Типи збігаються автоматично

Переваги:

  • Якщо змінюється реалізація getUserData, тип UserData оновиться автоматично
  • Не потрібно дублювати складні структури типів
  • Single source of truth: функція є джерелом істини про структуру даних

Практичний приклад: API клієнт

class ApiClient {
  async fetchProducts() {
    const response = await fetch('/api/products');
    return response.json() as Array<{
      id: string;
      name: string;
      price: number;
      category: string;
    }>;
  }

  async fetchUser(id: string) {
    const response = await fetch(`/api/users/${id}`);
    return response.json() as {
      id: string;
      email: string;
      profile: {
        firstName: string;
        lastName: string;
      };
    };
  }
}

const client = new ApiClient();

// Автоматично отримуємо типи з методів
type Products = Awaited<ReturnType<typeof client.fetchProducts>>;
// Products: Array<{ id: string; name: string; price: number; category: string }>

type User = Awaited<ReturnType<typeof client.fetchUser>>;
// User: { id: string; email: string; profile: { ... } }

async function displayProducts(products: Products): Promise<void> {
  products.forEach(p => console.log(`${p.name}: ${p.price} грн`));
}

const products = await client.fetchProducts();
displayProducts(products);
Awaited<T> utility type: Якщо функція повертає Promise<T>, ReturnType поверне Promise<T>, а не сам T. Щоб "розгорнути" Promise, використовуйте Awaited<ReturnType<...>>:
async function fetchData() {
  return { value: 42 };
}

type DirectReturn = ReturnType<typeof fetchData>;
// DirectReturn: Promise<{ value: number }>

type UnwrappedReturn = Awaited<ReturnType<typeof fetchData>>;
// UnwrappedReturn: { value: number }

Внутрішня реалізація ReturnType

type ReturnType<T extends (...args: any) => any> = 
  T extends (...args: any) => infer R ? R : any;

Механіка:

  • T extends (...args: any) => any — constraint перевіряє, що T є функціональним типом
  • infer R — виводить (infers) тип повернення функції у змінну R
  • Conditional type повертає R, якщо виведення успішне

Ключове слово infer дозволяє TypeScript автоматично визначити частину типу (у даному випадку — тип повернення).


Parameters: отримання типів параметрів функції

Parameters<T> повертає кортеж типів параметрів функції T. Це корисно для створення функцій-обгорток або проксі.

Синтаксис та приклад

function createUser(
  email: string,
  password: string,
  options: { role: string; isActive: boolean }
): User {
  return {
    id: generateId(),
    email,
    password: hashPassword(password),
    ...options,
    createdAt: new Date()
  };
}

// Отримуємо типи параметрів як кортеж
type CreateUserParams = Parameters<typeof createUser>;
// Результат:
// type CreateUserParams = [
//   email: string,
//   password: string,
//   options: { role: string; isActive: boolean }
// ]

// Можна створити функцію-обгортку з тими самими параметрами
function createUserWithLogging(...args: CreateUserParams): User {
  console.log('Створення користувача з email:', args[0]);
  const user = createUser(...args);
  console.log('Користувача створено з ID:', user.id);
  return user;
}

createUserWithLogging(
  'user@example.com',
  'password123',
  { role: 'admin', isActive: true }
);

Практичний приклад: декоратор retry

async function fetchData(url: string, options: RequestInit): Promise<Response> {
  return fetch(url, options);
}

type FetchDataParams = Parameters<typeof fetchData>;

async function withRetry<T extends (...args: any[]) => Promise<any>>(
  fn: T,
  maxRetries: number = 3
): (...args: Parameters<T>) => ReturnType<T> {
  return async (...args: Parameters<T>) => {
    let lastError: Error | undefined;
    
    for (let attempt = 0; attempt < maxRetries; attempt++) {
      try {
        return await fn(...args);
      } catch (error) {
        lastError = error as Error;
        console.log(`Спроба ${attempt + 1} невдала, повтор...`);
      }
    }
    
    throw lastError || new Error('Усі спроби вичерпано');
  };
}

const fetchWithRetry = withRetry(fetchData);

// ✅ Типи параметрів та повернення збереглися
const response = await fetchWithRetry('https://api.example.com/data', {
  method: 'GET'
});

Внутрішня реалізація Parameters

type Parameters<T extends (...args: any) => any> = 
  T extends (...args: infer P) => any ? P : never;

Аналогічно ReturnType, але infer P витягує типи параметрів як кортеж.


NonNullable: виключення null та undefined

NonNullable<T> видаляє null та undefined з union типу T.

Синтаксис та приклад

type MaybeString = string | null | undefined;

type DefinitelyString = NonNullable<MaybeString>;
// Результат: string

function processValue(value: string | null | undefined): void {
  if (value !== null && value !== undefined) {
    const safeValue: NonNullable<typeof value> = value;
    console.log(safeValue.toUpperCase());  // ✅ безпечно
  }
}

Практичний приклад: фільтрація масиву

interface Product {
  id: string;
  name: string;
  price: number;
}

function findProductById(id: string): Product | null {
  // пошук продукту
  return null;
}

const productIds = ['prod-1', 'prod-2', 'prod-3'];

const products: (Product | null)[] = productIds.map(findProductById);
// products: (Product | null)[]

// Фільтруємо null значення
const validProducts: NonNullable<(typeof products)[number]>[] = 
  products.filter((p): p is NonNullable<typeof p> => p !== null);

// validProducts: Product[]

Внутрішня реалізація NonNullable

type NonNullable<T> = T extends null | undefined ? never : T;

Conditional type видаляє null та undefined через never.


Композиція utility types: складні трансформації

Справжня сила utility types розкривається при їх композиції — комбінуванні кількох трансформацій для складних перетворень типів.

Приклад 1: Оновлення вкладеної структури

interface User {
  id: string;
  email: string;
  profile: {
    firstName: string;
    lastName: string;
    age: number;
    address: {
      city: string;
      street: string;
      zipCode: string;
    };
  };
  settings: {
    theme: 'light' | 'dark';
    notifications: boolean;
  };
}

// Для оновлення профілю: виключаємо id, profile стає опціональним
type UpdateUserProfileDto = Partial<Omit<User, 'id'>> & {
  profile?: Partial<User['profile']>;
};

function updateUserProfile(
  userId: string,
  updates: UpdateUserProfileDto
): User {
  const user = findUserById(userId);
  return {
    ...user,
    ...updates,
    profile: updates.profile ? { ...user.profile, ...updates.profile } : user.profile
  };
}

updateUserProfile('user-1', {
  email: 'newemail@example.com',
  profile: {
    age: 28  // оновлюємо лише вік, інші поля залишаються
  }
});

Приклад 2: API Response типи

interface ApiResponse<T> {
  data: T;
  error: string | null;
  meta: {
    timestamp: Date;
    requestId: string;
  };
}

type Product = {
  id: string;
  name: string;
  price: number;
};

// Для успішної відповіді error завжди null
type SuccessResponse<T> = Omit<ApiResponse<T>, 'error'> & { error: null };

// Для помилки data завжди null
type ErrorResponse = Omit<ApiResponse<null>, 'data'> & {
  data: null;
  error: string;
};

type ProductResponse = SuccessResponse<Product> | ErrorResponse;

function handleProductResponse(response: ProductResponse): void {
  if (response.error === null) {
    // TypeScript знає: response.data має тип Product
    console.log(`Продукт: ${response.data.name}`);
  } else {
    // TypeScript знає: response.data має тип null
    console.error(`Помилка: ${response.error}`);
  }
}

Приклад 3: Readonly вкладених структур (DeepReadonly)

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object
    ? DeepReadonly<T[P]>
    : T[P];
};

interface Config {
  api: {
    baseUrl: string;
    timeout: number;
    endpoints: {
      users: string;
      products: string;
    };
  };
  features: {
    darkMode: boolean;
    analytics: boolean;
  };
}

const config: DeepReadonly<Config> = {
  api: {
    baseUrl: 'https://api.example.com',
    timeout: 5000,
    endpoints: {
      users: '/users',
      products: '/products'
    }
  },
  features: {
    darkMode: true,
    analytics: false
  }
};

// ❌ Error: всі рівні readonly
// config.api.baseUrl = 'https://other.com';
// config.api.endpoints.users = '/api/users';
// config.features.darkMode = false;
Обмеження DeepReadonly: Приведена реалізація не працює для масивів, Map, Set та інших складних типів. Для production використовуйте бібліотеки з перевіреними реалізаціями (ts-essentials, type-fest).

Підсумок та ключові висновки

Utility types є потужним інструментом для трансформації типів без дублювання коду. Вони дозволяють створювати похідні типи на основі існуючих, автоматично синхронізуючи зміни та забезпечуючи типобезпеку.

Ключові тези розділу:

  • Partial робить всі властивості опціональними (для функцій оновлення).
  • Required робить всі властивості обов'язковими (для валідації повноти даних).
  • Readonly робить всі властивості незмінними (для конфігурацій та констант).
  • Pick<T, K> вибирає підмножину властивостей (для проєкцій даних).
  • Omit<T, K> виключає властивості (для DTO без службових полів).
  • Record<K, T> створює об'єкт з фіксованими ключами (для типобезпечних словників).
  • Extract<T, U> залишає типи з union, що присутні в U (фільтрація union).
  • Exclude<T, U> видаляє типи з union, що присутні в U (виключення з union).
  • ReturnType витягує тип повернення функції (уникнення дублювання).
  • Parameters витягує типи параметрів функції (для обгорток).
  • NonNullable видаляє null та undefined (для безпечних операцій).

Практичні рекомендації:

  1. Використовуйте utility types для автоматичної синхронізації похідних типів з батьківськими.
  2. Композиція utility types дозволяє створювати складні трансформації типів.
  3. Для публічних API віддавайте перевагу явним інтерфейсам замість складних utility types для кращої документації.
  4. Розуміння внутрішньої реалізації через mapped та conditional types допомагає створювати власні utility types.
  5. Для складних рекурсивних трансформацій (DeepReadonly, DeepPartial) використовуйте перевірені бібліотеки (ts-essentials, type-fest).
Copyright © 2026