Пошук уроків, статей та іншого контенту
Дослідіть conditional types, mapped types, infer і розподіл об’єднань, на яких побудовані Utility Types.
Utility Types — це готові узагальнені типи TypeScript, які перетворюють інші типи:
Partial<T> робить усі властивості необов’язковими;
Readonly<T> додає властивостям модифікатор readonly;
Pick<T, K> залишає вибрані властивості;
Omit<T, K> виключає властивості;
Exclude<T, U> вилучає члени об’єднання;
Extract<T, U> залишає сумісні члени;
ReturnType<T> отримує тип результату функції;
Parameters<T> отримує типи параметрів функції.
Ці типи не є особливими примітивами мови. Більшість із них можна побудувати за допомогою чотирьох механізмів:
conditional types;
mapped types;
infer;
розподілу conditional types над об’єднаннями.
Знання цих механізмів дає змогу не лише використовувати готові Utility Types, а й створювати власні.
Умова типового рівня має синтаксис:
T extends U ? X : YВона читається так:
якщо
Tсумісний ізU, результатом будеX, інакше —Y.
Це аналог умовного оператора, але для типів.
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // falseСумісність перевіряється з урахуванням правил структурної типізації TypeScript:
type HasId<T> = T extends { id: string } ? true : false;
type A = HasId<{ id: string; name: string }>; // true
type B = HasId<{ name: string }>; // falseТип із додатковими властивостями сумісний із типом, який вимагає лише id.
nevernever означає тип, якого не може мати жодне значення. У conditional types він має особливе значення, особливо разом із розподілом об’єднань.
type IsString<T> = T extends string ? true : false;
type Result = IsString<never>; // neverЦе не true і не false. Conditional type не обчислюється для відсутнього типу.
Якщо conditional type перевіряє безпосередньо параметр-тип, TypeScript застосовує його до кожного члена об’єднання окремо:
type IsString<T> = T extends string ? true : false;
type Result = IsString<string | number>;
// IsString<string> | IsString<number>
// true | falseФактично TypeScript обчислює:
type Result =
(string extends string ? true : false) |
(number extends string ? true : false);Отже:
type Result = true | false;Це називається дистрибутивним, або розподільним, conditional type.
Розподіл відбувається, коли перевіряється саме параметр типу:
type Distributive<T> = T extends unknown ? T[] : never;
type Result = Distributive<string | number>;
// string[] | number[]Тут об’єднання розділяється перед перевіркою.
Розподіл не відбувається, якщо параметр загорнути в інший тип:
type NonDistributive<T> = [T] extends [unknown] ? T[] : never;
type Result = NonDistributive<string | number>;
// (string | number)[]У першому випадку:
string[] | number[]У другому:
(string | number)[]Це важлива техніка для керування поведінкою conditional types.
Exclude та ExtractExclude<T, U> вилучає з T усі члени, сумісні з U:
type MyExclude<T, U> = T extends U ? never : T;
type Status = "idle" | "loading" | "success" | "error";
type WithoutLoading = MyExclude<Status, "loading">;
// "idle" | "success" | "error"Розподіл відбувається для кожного члена Status:
type WithoutLoading =
("idle" extends "loading" ? never : "idle") |
("loading" extends "loading" ? never : "loading") |
("success" extends "loading" ? never : "success") |
("error" extends "loading" ? never : "error");Після обчислення:
never | "idle" | never | "success" | "error"never прибирається з об’єднання, тому результатом залишається:
"idle" | "success" | "error"Extract<T, U> працює навпаки:
type MyExtract<T, U> = T extends U ? T : never;
type Values = string | number | boolean;
type OnlyPrimitive = MyExtract<Values, string | number>;
// string | numberВбудовані типи TypeScript концептуально мають такий вигляд:
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;Mapped type перебирає ключі іншого типу та створює новий тип:
type Flags<T> = {
[K in keyof T]: boolean;
};
type Options = {
darkMode: boolean;
compactMode: boolean;
};
type OptionFlags = Flags<Options>;
// {
// darkMode: boolean;
// compactMode: boolean;
// }Синтаксис:
{
[K in keyof T]: SomeType
}keyof T отримує об’єднання ключів;
K — поточний ключ;
значення після : визначає тип відповідної властивості.
PartialPartial<T> перебирає всі ключі T і додає модифікатор ?:
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
type User = {
id: string;
name: string;
age: number;
};
type UserUpdate = MyPartial<User>;
// {
// id?: string;
// name?: string;
// age?: number;
// }T[K] — це indexed access type. Він отримує тип властивості K.
type UserName = User["name"]; // string
type UserField = User[keyof User]; // string | numberReadonlytype MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
type Config = MyReadonly<{
host: string;
port: number;
}>;Тепер спроба змінити властивість призведе до помилки:
const config: Config = {
host: "localhost",
port: 3000,
};
// Помилка: властивість доступна лише для читання
// config.port = 4000;Mapped type може змінювати не лише типи властивостей, а й їхні модифікатори.
Перед модифікатором можна поставити -, щоб його видалити:
type RequiredVersion<T> = {
[K in keyof T]-?: T[K];
};
type MutableVersion<T> = {
-readonly [K in keyof T]: T[K];
};-? робить властивості обов’язковими, а -readonly дозволяє змінювати їх.
type Draft = {
readonly id: string;
name?: string;
};
type EditableDraft = MutableVersion<RequiredVersion<Draft>>;
// {
// id: string;
// name: string;
// }Вбудовані типи Partial, Required, Readonly і Mutable-подібні власні типи побудовані навколо цього механізму.
Pick та OmitPick<T, K> залишає лише ключі з K:
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
type User = {
id: string;
name: string;
email: string;
};
type UserPreview = MyPick<User, "id" | "name">;
// {
// id: string;
// name: string;
// }Обмеження K extends keyof T не дозволяє вибрати ключ, якого немає в T.
Omit<T, K> можна побудувати через Pick та Exclude:
type MyOmit<T, K extends PropertyKey> = {
[P in Exclude<keyof T, K>]: T[P];
};
type PublicUser = MyOmit<User, "email">;
// {
// id: string;
// name: string;
// }PropertyKey — це об’єднання допустимих ключів об’єкта:
type PropertyKey = string | number | symbol;Тому Omit може працювати не лише зі строковими ключами.
infer: отримання частини типуinfer дозволяє оголосити типову змінну всередині умови та вивести її з іншого типу.
type UnwrapArray<T> = T extends Array<infer Item> ? Item : never;
type A = UnwrapArray<string[]>; // string
type B = UnwrapArray<number[]>; // number
type C = UnwrapArray<string>; // neverУ виразі:
T extends Array<infer Item>TypeScript перевіряє, чи є T масивом. Якщо так, тип елементів записується в Item.
Це схоже на вилучення частини шаблону:
type Pair<T> = T extends [infer First, infer Second]
? [Second, First]
: never;
type Reversed = Pair<[string, number]>;
// [number, string]ReturnTypeReturnType<T> отримує тип, який повертає функція:
type MyReturnType<T> =
T extends (...args: never[]) => infer Result
? Result
: never;
type GetUser = () => { id: string };
type User = MyReturnType<GetUser>;
// { id: string }Якщо T не є функцією, результатом буде never:
type Invalid = MyReturnType<string>; // neverУ стандартній бібліотеці визначення може використовувати any замість never у списку параметрів:
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : any;Суть однакова: infer R вилучає тип результату.
Функції з параметрами також обробляються правильно:
type CreateUser = (name: string, age: number) => {
id: string;
name: string;
age: number;
};
type CreatedUser = MyReturnType<CreateUser>;
// {
// id: string;
// name: string;
// age: number;
// }ParametersЩоб отримати параметри функції, потрібно вивести тип із кортежу:
type MyParameters<T> =
T extends (...args: infer Params) => unknown
? Params
: never;
type Handler = (event: MouseEvent, id: string) => void;
type HandlerParams = MyParameters<Handler>;
// [event: MouseEvent, id: string]Параметри функції в TypeScript представлені кортежем, тому результат зберігає:
порядок параметрів;
кількість параметрів;
тип кожного параметра;
опційність параметрів;
rest-параметри.
type Log = (message: string, ...values: number[]) => void;
type LogParams = MyParameters<Log>;
// [message: string, ...values: number[]]infer у різних позиціяхЯкщо infer зустрічається кілька разів, TypeScript намагається узгодити всі знайдені типи.
type FirstArgument<T> =
T extends (first: infer First, ...rest: unknown[]) => unknown
? First
: never;
type Result = FirstArgument<(id: string, active: boolean) => void>;
// stringinfer може працювати з конструкторами:
type ConstructorInstance<T> =
T extends new (...args: never[]) => infer Instance
? Instance
: never;
class User {
constructor(public id: string) {}
}
type UserInstance = ConstructorInstance<typeof User>;
// Usertypeof User — це тип конструктора, а infer Instance отримує тип екземпляра.
inferОскільки conditional types розподільні, infer може окремо обробити кожен член об’єднання:
type ElementType<T> =
T extends readonly (infer Item)[] ? Item : never;
type Items = ElementType<string[] | number[]>;
// string | numberОбчислення відбувається приблизно так:
type Items =
ElementType<string[]> |
ElementType<number[]>;
// string | numberЦе корисно для перетворення об’єднань типів, але може бути небажаним, якщо потрібно перевірити об’єднання як єдине ціле.
type IsUnionArray<T> =
[T] extends [readonly (infer Item)[]]
? Item
: never;
type Result = IsUnionArray<string[] | number[]>;
// string | numberУ цьому прикладі перевірка обгорнута в кортеж. TypeScript порівнює все об’єднання цілком, а не запускає умову для кожного члена окремо.
Практичний приклад:
type IsExactlyString<T> =
[T] extends [string]
? [string] extends [T]
? true
: false
: false;
type A = IsExactlyString<string>; // true
type B = IsExactlyString<"hello">; // false
type C = IsExactlyString<string | number>; // falseДруга перевірка потрібна, щоб "hello" не вважався точно рівним string: літеральний тип лише сумісний із string, але не дорівнює йому.
Mapped types можуть змінювати самі ключі за допомогою as.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type User = {
name: string;
age: number;
};
type UserGetters = Getters<User>;
// {
// getName: () => string;
// getAge: () => number;
// }string & K потрібен, щоб гарантувати сумісність K із шаблонним літеральним типом string.
Ключ можна вилучити, перетворивши його на never:
type RemoveMethods<T> = {
[K in keyof T as T[K] extends (...args: never[]) => unknown
? never
: K]: T[K];
};
type Service = {
name: string;
start(): void;
stop(): void;
};
type ServiceData = RemoveMethods<Service>;
// {
// name: string;
// }Тут conditional type перевіряє тип кожної властивості. Для методів результатом ключа стає never, тому ці властивості не потрапляють у результат.
Наступний приклад можна зберегти у файл utility-types.ts і перевірити компілятором TypeScript. Він містить власні реалізації кількох Utility Types і використовує їх у звичайному коді.
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;
type MyReturnType<T> =
T extends (...args: never[]) => infer Result
? Result
: never;
type MyParameters<T> =
T extends (...args: infer Params) => unknown
? Params
: never;
type User = {
id: string;
name: string;
age: number;
active: boolean;
};
type UserUpdate = MyPartial<User>;
type UserPreview = MyPick<User, "id" | "name">;
type ActiveUser = MyPick<User, "active">;
type Status = "idle" | "loading" | "success" | "error";
type FinishedStatus = MyExclude<Status, "idle" | "loading">;
type LoadingStatus = MyExtract<Status, "loading" | "error">;
function createUser(name: string, age: number) {
return {
id: crypto.randomUUID(),
name,
age,
active: true,
};
}
type CreateUserResult = MyReturnType<typeof createUser>;
type CreateUserArguments = MyParameters<typeof createUser>;
const update: UserUpdate = {
name: "Олена",
};
const preview: UserPreview = {
id: "user-1",
name: "Олена",
};
const user: CreateUserResult = createUser("Олена", 28);
function callCreateUser(...args: CreateUserArguments): CreateUserResult {
return createUser(...args);
}
console.log(update);
console.log(preview);
console.log(user);
console.log(callCreateUser("Андрій", 31));
const status: FinishedStatus = "success";
const loading: LoadingStatus = "loading";
console.log(status, loading);У цьому прикладі:
MyPartial використовує mapped type і ?;
MyPick перебирає обмежену множину ключів;
MyExclude та MyExtract використовують розподільні conditional types;
MyReturnType виводить тип результату через infer;
MyParameters виводить кортеж аргументів через infer.
T[] | U[] і (T | U)[]Ці типи різні:
type Separate = string[] | number[];
type Mixed = (string | number)[];Separate описує масив, який повністю складається або зі строк, або з чисел.
Mixed дозволяє змішувати значення:
const mixed: Mixed = ["text", 42, "another"];Дистрибутивний conditional type часто створює саме об’єднання окремих результатів:
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// string[] | number[]Якщо потрібен один масив зі змішаними елементами, треба вимкнути розподіл:
type ToSingleArray<T> = [T] extends [unknown] ? T[] : never;
type Result = ToSingleArray<string | number>;
// (string | number)[]nevernever видаляється з об’єднань:
type Result = string | never;
// stringСаме тому Exclude може вилучати члени об’єднання, повертаючи never для непотрібних членів.
any без потребиany вимикає значну частину перевірок типів і може приховати помилки. У власних conditional types краще використовувати точні обмеження:
type SafeReturnType<T> =
T extends (...args: never[]) => infer Result
? Result
: never;Водночас визначення стандартних Utility Types можуть містити any через особливості сумісності функціональних типів. Важливо розуміти різницю між реалізацією для навчання та точним визначенням у конкретній версії бібліотеки TypeScript.
infer працює поза conditional typeinfer дозволений лише всередині умови:
type Valid<T> = T extends Promise<infer Value> ? Value : T;Такий запис некоректний:
// Некоректно:
// type Invalid<T> = Promise<infer Value>;Pick потребує ключі, які існують у вихідному типі:
type User = {
id: string;
};
type Preview = MyPick<User, "id">;
// Помилка:
// type Invalid = MyPick<User, "email">;Обмеження K extends keyof T є частиною безпеки такого Utility Type.
Conditional type має форму T extends U ? X : Y.
Conditional type розподіляється над об’єднанням, якщо перевіряється безпосередній параметр типу.
Обгортання параметра в кортеж [T] вимикає розподіл.
never використовується для вилучення типів з об’єднань.
Mapped types перебирають ключі через keyof і можуть змінювати типи та модифікатори властивостей.
? і readonly додають модифікатори, а -? і -readonly їх видаляють.
infer дає змогу отримати частину типу всередині conditional type.
ReturnType виводить тип результату функції, а Parameters — кортеж її параметрів.
Pick, Omit, Exclude, Extract, Partial і Readonly будуються з цих фундаментальних механізмів.
Key remapping дає змогу перейменовувати або вилучати ключі в mapped types.