Master TypeScript's Built-In Utility Types for Cleaner Code
Learn how TypeScript's utility types like Partial, Pick, Omit, and Record eliminate duplicate interfaces and keep type definitions in sync automatically.
Хватит копировать и вставлять интерфейсы: пишите более чистый и надежный код с использованием встроенных преобразователей типов TypeScript.
Рано или поздно каждый разработчик на TypeScript сталкивается с одной и той же проблемой. Вы создаете чистый интерфейс User с восемью полями. Через несколько минут вам нужна форма для редактирования, где на самом деле требуются только два поля, поэтому вы создаете интерфейс UpdateUserInput. Затем появляется ответ от API, в котором необходимо скрыть конфиденциальные поля, и вы добавляете еще один интерфейс — PublicUser. Прежде чем вы это осознаете, значительная часть вашего кодового базиса состоит из почти идентичных интерфейсов, которые постепенно теряют синхронизацию по мере роста проекта.
Каждый раз, когда меняется основная схема, вам приходится искать и обновлять несколько мест одновременно. Если вы забудете об одном из них, это приведет к появлению ошибки, которая проявится позже в продакшене.
TypeScript решает эту проблему с помощью встроенного набора инструментов под названием Utility Types. Их можно рассматривать как функции, которые работают с типами вместо значений: вы передаете существующий тип, применяете определённое правило трансформации и получаете совершенно новый тип. Это позволяет сохранять единственное авторитетное определение и передавать всю работу по повторной обработке компилятору.
Ниже приведены утилитные типы, которые стоит использовать постоянно, описанные простым языком с реальными примерами кода, которые можно сразу адаптировать.
1. Изменение строгости свойств
Эти три утилиты контролируют, являются ли поля необязательными, обязательными или замороженными.
Partial<Type>
Простыми словами: «Сделать все свойства необязательными».
Когда вы создаете что-то вроде конца точки обработки PATCH или обновляете часть состояния приложения, вам не нужно запрашивать у вызывающего кода всё объекты целиком. Partial<T> проходит по каждому полю интерфейса и добавляет к каждому из них знак ?.
interface UserProfile {
id: string;
name: string;
email: string;
avatarUrl: string;
theme: "light" | "dark";
}
// Without Partial, you'd have to rewrite all fields with '?'
type UpdateProfileInput = Partial<UserProfile>;
function updateProfile(userId: string, changes: UpdateProfileInput) {
// Valid: changes can include any subset of UserProfile properties
apiClient.patch(`/users/${userId}`, changes);
}
// Valid call:
updateProfile("usr_101", { theme: "dark" });
Required<Type>
Простыми словами: «Удалите все флаги необязательности, чтобы ничего нельзя было пропустить».
Required<T> делает противоположное Partial<T>: если у интерфейса есть необязательные поля, он заставляет все они присутствовать. Это полезно при объединении стандартных настроек, когда необходимо гарантировать отсутствие пропущенных элементов.
interface AppConfig {
apiUrl: string;
timeout?: number;
retries?: number;
enableLogging?: boolean;
}
// Ensure the internal runner has every single setting resolved
type ResolvedConfig = Required<AppConfig>;
const defaultConfig: ResolvedConfig = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
enableLogging: true,
};
function initializeApp(userOptions: AppConfig): ResolvedConfig {
return { ...defaultConfig, ...userOptions };
}
Readonly<Type>
Простыми словами: «Заблокируйте все свойства, чтобы их нельзя было переназначить».
Когда вам нужно защититься от случайных изменений — например, значений конфигурации, параметров компонентов или общего состояния приложения — флаг Readonly<T> делает все свойства только для чтения. Любая попытка записи в такие свойства проваливается на этапе компиляции.
interface SystemRole {
roleName: string;
permissions: string[];
}
const SuperAdminRole: Readonly<SystemRole> = {
roleName: "SUPER_ADMIN",
permissions: ["read", "write", "delete", "admin"],
};
// Error: Cannot assign to 'roleName' because it is a read-only property.
SuperAdminRole.roleName = "USER";
2. Выбор необходимых элементов
Существует множество ситуаций, когда вам нужна лишь часть интерфейса. Вместо того чтобы создавать совершенно новый, не связанный тип, вы можете взять нужные элементы непосредственно из существующих моделей домена.
Pick<Type, Keys>
Простыми словами: «Создайте новый тип, используя только эти указанные ключи из исходного».
Предположим, у вас есть большая модель, но небольшой UI-виджет или легкий запрос к базе данных нуждается только в имени и аватаре. Вы можете указать TypeScript сохранять лишь эти два поля.
interface Product {
id: string;
sku: string;
name: string;
price: number;
stockQuantity: number;
supplierId: string;
description: string;
}
// Pick only the fields a mini checkout card displays
type CartItemPreview = Pick<Product, "id" | "name" | "price">;
const item: CartItemPreview = {
id: "prod_99",
name: "Wireless Mechanical Keyboard",
price: 129.99,
};
Omit<Type, Keys>
Простыми словами: "Возьмите полный исходный тип, но уберите эти конкретные поля."
Omit<T, K> противоположен операции Pick. При вставке новой записи в базу данных обычно сохраняются все поля, кроме тех, которые система генерирует автоматически, таких как id или createdAt.
interface Article {
id: number;
title: string;
slug: string;
content: string;
publishedAt: Date;
viewCount: number;
}
// Strip metadata handled by the database
type CreateArticlePayload = Omit<Article, "id" | "viewCount" | "publishedAt">;
function submitArticle(payload: CreateArticlePayload) {
// TypeScript guarantees no one accidentally submits an 'id' or 'viewCount'
apiClient.post("/articles", payload);
}
3. Динамическое сопоставление ключей
Record<Keys, Type>
Простыми словами: «Создайте словарь, ключи которого берутся из этого списка, а значения имеют определенную структуру».
Обычные объекты JavaScript, используемые в качестве таблиц поиска, часто имеют нестрогую типизацию, например в виде { [key: string]: any }, что нарушает принципы безопасности типов. Вместо этого Record<Keys, Type> строго определяет, какие ключи допускаются и какой должна быть структура их значений.
type SubscriptionTier = "free" | "pro" | "enterprise";
interface TierFeatures {
monthlyQuota: number;
customDomainAllowed: boolean;
supportLevel: "community" | "email" | "dedicated";
}
// Guarantees all three tiers are explicitly handled
const subscriptionPlans: Record<SubscriptionTier, TierFeatures> = {
free: {
monthlyQuota: 1000,
customDomainAllowed: false,
supportLevel: "community",
},
pro: {
monthlyQuota: 50000,
customDomainAllowed: true,
supportLevel: "email",
},
enterprise: {
monthlyQuota: 1000000,
customDomainAllowed: true,
supportLevel: "dedicated",
},
};
Если позже в SubscriptionTier будет добавлен новый уровень "starter", TypeScript сразу же отметит subscriptionPlans и сообщит о отсутствии записи "starter">.
4. Упрощение и сужение типов объединения
Вышеупомянутые утилиты изменяют структуру ключей объектов. Следующая пара утилит работает с объединёнными типами, такими как "apple" | "banana" | "orange".
Exclude<UnionType, ExcludedMembers>
Простыми словами: "Возьмите этот набор возможных значений и удалите те, которые указаны здесь."
type TaskStatus = "draft" | "in_progress" | "review" | "completed" | "archived";
// You can edit tasks in any status except when they are completed or archived
type EditableTaskStatus = Exclude<TaskStatus, "completed" | "archived">;
// Result: "draft" | "in_progress" | "review"
Extract<UnionType, ExtractedMembers>
Простыми словами: "Возьмите этот набор возможных значений и оставьте только те, которые соответствуют этим критериям."
Extract работает в обратном направлении по сравнению с Exclude: он выделяет пересечение между двумя объединёнными типами, сохраняя только те элементы, которые присутствуют в обоих.
type WindowEvents = "click" | "scroll" | "mousemove" | "keydown" | "keyup";
type PointerEvents = "click" | "mouseenter" | "mouseleave" | "mousemove";
// Keep only the events shared across both categories
type SharedEvents = Extract<WindowEvents, PointerEvents>;
// Result: "click" | "mousemove"
NonNullable<Type>
Простыми словами: устраняет значения null и undefined из типа.
При работе с данными, поступающими из внешних API или клиентов баз данных, поля часто задаются как string | null | undefined. NonNullable<T> убирает эти возможности пустых значений, оставляя только значимые типы.
type SearchQuery = string | string[] | null | undefined;
type CleanSearchQuery = NonNullable<SearchQuery>;
// Result: string | string[]
5. Определение типов на основе существующего кода
Одним из главных преимуществ TypeScript является возможность автоматического получения типов непосредственно из функций, обещаний и импортируемых модулей вместо их ручного повторного объявления.
ReturnType<FunctionType>
Простыми словами: он возвращает то, что возвращает функция, поэтому вам никогда не придётся вручную перепечатывать эту структуру.
Для сложных функций фабрики, селекторов в стиле Redux или сторонних пакетов, которые не раскрывают свои форматы возвращаемых значений, вы можете получить точную структуру, обратившись к самой функции.
function buildSessionPayload(userId: string, roles: string[]) {
return {
sessionId: crypto.randomUUID(),
authenticatedAt: Date.now(),
expiresAt: Date.now() + 1000 * 60 * 60 * 24,
account: {
id: userId,
primaryRole: roles[0] ?? "guest",
allRoles: roles,
},
};
}
// Automatically inherits the shape of whatever buildSessionPayload returns
type UserSession = ReturnType<typeof buildSessionPayload>;
Если объект, возвращаемый внутри buildSessionPayload, позже изменится, UserSession автоматически учтёт это изменение.
Parameters<FunctionType>
Простыми словами: он генерирует кортеж, содержащий список всех аргументов, которые принимает функция.
Каждый раз, когда вы оборачиваете метод SDK, пересылаете аргументы или создаёте слой логирования вокруг вызова функции, Parameters<T> гарантирует, что сигнатура вашего обёртки никогда не отклонится от оригинальной.
function triggerAlert(message: string, severity: "low" | "high", code?: number) {
// Internal dispatch logic
}
// Extracts arguments as a tuple: [message: string, severity: "low" | "high", code?: number]
type AlertArgs = Parameters<typeof triggerAlert>;
function logAndAlert(...args: AlertArgs) {
console.log("Dispatching alert:", args[0]);
triggerAlert(...args);
}
Awaited<Type>
Простыми словами: это функция, которая распаковывает объект Promise и выделяет находящееся внутри значение.
Поскольку современный TypeScript во многом основан на асинхронном коде, Awaited<T> крайне важен для постепенного снятия одного или нескольких уровней вложенных обещаний, пока не будет достигнуто финальное решенное значение.
async function fetchAccountDetails() {
return {
accountId: "acct_8472",
balance: 4250.75,
currency: "USD",
};
}
// Unwraps the Promise<T> returned by the async function
type AccountDetails = Awaited<ReturnType<typeof fetchAccountDetails>>;
// Result: { accountId: string; balance: number; currency: string }
const cachedAccount: AccountDetails = {
accountId: "acct_8472",
balance: 4250.75,
currency: "USD",
};
6. Использование вспомогательных типов как строительных блоков
Вспомогательные типы демонстрируют свою настоящую силу при комбинировании. Поскольку каждый из них возвращает стандартный тип TypeScript, результат работы одного такого типа можно сразу использовать в качестве входных данных для другого.
Рассмотрим слой данных, построенный на сущности Customer:
interface Customer {
id: string;
firstName: string;
lastName: string;
email: string;
phoneNumber?: string;
createdAt: Date;
updatedAt: Date;
}
// 1. Creation payload: No auto-generated fields, but firstName, lastName, and email are mandatory
type CreateCustomerDTO = Omit<Customer, "id" | "createdAt" | "updatedAt">;
// 2. Update payload: Omit IDs, and let the user modify ANY valid field optionally
type UpdateCustomerDTO = Partial<Omit<Customer, "id" | "createdAt" | "updatedAt">>;
// 3. Read-only view for UI caches
type ReadonlyCustomer = Readonly<Customer>;
Всего за несколько строк вы создали три отдельных объекта передачи данных с полной проверкой типов. Если базовый интерфейс Customer позже получит новое свойство — скажем, loyaltyTier — все три производных типа автоматически обновятся без необходимости вручную вносить изменения.
Краткое резюме
Рассмотрение типов как вручную скопированных копий превращает каждую изменение схемы в нагрузку по техническому обслуживанию. Утилитные типы позволяют определить бизнес-модели один раз, и компилятор будет генерировать все необходимые варианты на основе этого единственного определения. В результате получается более лаконичный код, более безопасные рефакторинги и меньше времени, тратимого на согласование почти идентичных интерфейсов, что позволяет сосредоточиться на разработке функций.
Связанная литература
- Моделирование доменов в TypeScript: за пределами базовых аннотаций типов — Изучите практические подходы в TypeScript: от различий между типами unknown и any до использования дискриминированных союзов и оператора satisfies, которые помогут вам моделировать корректные состояния вместо простого маркирования данных.
- Распространённые ошибки в JavaScript и TypeScript, которые тайно ломают код — Рассматриваются тонкие нюансы JavaScript и TypeScript: от сравнений с NaN до проблем с асинхронными операциями и принудительной конвертацией типов, которые вызывают ошибки, несмотря на кажущуюся корректность кода.