Utility Types
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 автоматично відобразиться у похідних типах — не потрібно нічого міняти вручну.
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 створюється опціональна властивість з тим самим типом значення.
interface User {
id: string;
profile: {
firstName: string;
lastName: string;
};
}
type PartialUser = Partial<User>;
const user: PartialUser = {
profile: {
firstName: 'Олександр'
// ❌ Error: Property 'lastName' is missing
}
};
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<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, коли похідний тип семантично пов'язаний з батьківським і має змінюватися разом з ним. Якщо це незалежна сутність, краще визначити окремий інтерфейс:// ✅ Добре: 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:
Exclude<keyof T, K>— з усіх ключівTвиключаємо ключі зKPick<T, ...>— вибираємо зTлише ті ключі, що залишилися після виключення
Наприклад, для Omit<User, 'id' | 'password'>:
keyof User→"id" | "email" | "password" | "firstName" | ...Exclude<keyof User, 'id' | 'password'>→"email" | "firstName" | ...Pick<User, "email" | "firstName" | ...>→ фінальний тип
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 ідеально підходить для створення типобезпечних мапінгів на основі 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;
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 (для безпечних операцій).
Практичні рекомендації:
- Використовуйте utility types для автоматичної синхронізації похідних типів з батьківськими.
- Композиція utility types дозволяє створювати складні трансформації типів.
- Для публічних API віддавайте перевагу явним інтерфейсам замість складних utility types для кращої документації.
- Розуміння внутрішньої реалізації через mapped та conditional types допомагає створювати власні utility types.
- Для складних рекурсивних трансформацій (DeepReadonly, DeepPartial) використовуйте перевірені бібліотеки (
ts-essentials,type-fest).
Вибір залежить від кількості полів:
- Pick — коли потрібно залишити кілька полів з багатьох (наприклад, 2 з 10)
- Omit — коли потрібно виключити кілька полів з багатьох (наприклад, виключити 2 з 10)
Обирайте варіант, що дає коротший код:
// 10 полів у User, потрібно лише 2 → Pick компактніший
type LoginData = Pick<User, 'email' | 'password'>;
// 10 полів у User, виключити 2 → Omit компактніший
type UserPublic = Omit<User, 'password' | 'secretKey'>;
Partial<T> працює лише на першому рівні вкладеності, оскільки його реалізація через mapped type ітерує лише по властивостях першого рівня:
type Partial<T> = {
[P in keyof T]?: T[P]; // рекурсії немає
};
Для рекурсивної опціональності потрібен кастомний тип:
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? DeepPartial<T[P]>
: T[P];
};
Проте будьте обережні з рекурсивними типами — вони можуть спричинити проблеми з продуктивністю компілятора та складні помилки типізації.
Використовуйте комбінацію Awaited та ReturnType:
async function fetchUser(id: string): Promise<{
id: string;
name: string
}> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// Тип з Promise
type PromiseUser = ReturnType<typeof fetchUser>;
// PromiseUser: Promise<{ id: string; name: string }>
// Розгорнутий тип (без Promise)
type User = Awaited<ReturnType<typeof fetchUser>>;
// User: { id: string; name: string }
Awaited<T> рекурсивно розгортає Promise (навіть Promise<Promise<T>> → T).
Використовуйте комбінацію mapped type та conditional type:
interface User {
id: string;
name: string;
age: number;
isActive: boolean;
createdAt: Date;
}
// Залишаємо лише ключі з типом string
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never
}[keyof T];
type UserStringKeys = StringKeys<User>;
// UserStringKeys: "id" | "name"
// Залишаємо лише ключі з типом number
type NumberKeys<T> = {
[K in keyof T]: T[K] extends number ? K : never
}[keyof T];
type UserNumberKeys = NumberKeys<User>;
// UserNumberKeys: "age"
Механіка: mapped type створює { id: "id", name: "name", age: never, ... }, потім [keyof T] витягує всі значення, а never зникає з union.
Так! Utility types — це звичайні generic типи, що використовують mapped та conditional types. Приклад кастомного utility type для створення типу з опціональними полями за виключенням певних:
type PartialExcept<T, K extends keyof T> =
Partial<T> & Pick<T, K>;
interface Product {
id: string;
name: string;
price: number;
description: string;
}
// Всі поля опціональні, крім id
type UpdateProduct = PartialExcept<Product, 'id'>;
// Результат:
// {
// id: string; // обов'язкове
// name?: string; // опціональне
// price?: number; // опціональне
// description?: string; // опціональне
// }
У наступному розділі ви навчитеся створювати складні кастомні utility types через mapped та conditional types.