Пошук уроків, статей та іншого контенту
Зберете практичні типи для API, валідаторів і трансформацій, поєднавши keyof, infer, mapped, умовні та рекурсивні типи.
Програмування на рівні типів — це побудова нових типів на основі вже наявних. На відміну від звичайних функцій, такі конструкції виконуються компілятором TypeScript і не створюють JavaScript-код.
Це особливо корисно, коли потрібно:
отримати тип даних із опису API;
вивести тип результату валідатора;
перетворити всі властивості об’єкта;
вибрати властивості за їхніми типами;
рекурсивно обробити вкладені об’єкти й масиви;
синхронізувати декларації та фактичну структуру даних.
Для цього поєднаємо:
keyof;
умовні типи;
infer;
mapped types;
key remapping;
рекурсивні типи.
keyof як отримання ключів типуОператор keyof створює об’єднання ключів типу:
type User = {
id: number;
name: string;
active: boolean;
};
type UserKey = keyof User;
// "id" | "name" | "active"Значення keyof User можна використовувати як обмеження для узагальненого параметра:
function getProperty<T, K extends keyof T>(object: T, key: K): T[K] {
return object[key];
}
const user = {
id: 10,
name: "Iryna",
active: true,
};
const name = getProperty(user, "name");
// string
const active = getProperty(user, "active");
// booleanТут важливі два моменти:
K extends keyof T гарантує, що ключ існує в об’єкті;
T[K] означає тип властивості, яку вибрали за ключем K.
Без цього TypeScript не зміг би безпечно пов’язати конкретний ключ із типом його значення.
Mapped type проходить по ключах іншого типу та створює новий тип.
type ReadonlyObject<T> = {
readonly [K in keyof T]: T[K];
};
type ReadonlyUser = ReadonlyObject<{
id: number;
name: string;
}>;Результат:
type ReadonlyUser = {
readonly id: number;
readonly name: string;
};Синтаксис:
type Result<T> = {
[K in keyof T]: /* тип властивості */;
};K in keyof T означає: «для кожного ключа K з типу T».
Можна змінювати модифікатори:
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type RequiredObject<T> = {
[K in keyof T]-?: T[K];
};
type OptionalObject<T> = {
[K in keyof T]?: T[K];
};Знак - видаляє модифікатор, а знак + додає його. + зазвичай можна не писати, оскільки він є значенням за замовчуванням.
У mapped type можна змінювати самі ключі через as.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<{
id: number;
name: string;
}>;Отримаємо:
type UserGetters = {
getId: () => number;
getName: () => string;
};string & K потрібен для того, щоб переконати TypeScript: ключ можна використовувати як рядок у шаблонному літералі.
Key remapping також дає змогу фільтрувати ключі. Якщо результат після as дорівнює never, властивість не потрапляє до нового типу.
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
type User = {
id: number;
name: string;
email: string;
};
type UserStringKeys = StringKeys<User>;
// "name" | "email"Внутрішній mapped type спочатку створює:
type Intermediate = {
id: never;
name: "name";
email: "email";
};А індексація [keyof T] об’єднує значення властивостей у "name" | "email".
Умовний тип має форму:
type Result<T> = T extends SomeType
? TypeIfTrue
: TypeIfFalse;Приклад:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>;
// true
type B = IsString<number>;
// falseУмовні типи часто використовують разом із keyof та mapped types.
type PickByValue<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};
type Product = {
id: number;
title: string;
price: number;
inStock: boolean;
};
type NumericProductFields = PickByValue<Product, number>;Результат:
type NumericProductFields = {
id: number;
price: number;
};Цей тип корисний, наприклад, для побудови конфігурації сортування:
type SortableFields<T> = keyof PickByValue<T, string | number>;
type ProductSortableField = SortableFields<Product>;
// "id" | "title" | "price"infer: отримання типу з іншого типуinfer дає змогу оголосити тимчасову змінну типу всередині умовного типу.
Наприклад, можна отримати тип, який повертає функція:
type ReturnTypeOf<T> = T extends (...args: never[]) => infer Result
? Result
: never;
type User = ReturnTypeOf<() => { id: number; name: string }>;
// { id: number; name: string }У стандартній бібліотеці TypeScript уже є вбудований тип ReturnType, але власна версія добре показує принцип.
type ElementType<T> = T extends readonly (infer Element)[]
? Element
: never;
type NumberElement = ElementType<readonly number[]>;
// number
type StringElement = ElementType<string[]>;
// stringinfer Element означає: якщо T є масивом, знайди тип його елемента та назви його Element.
type EndpointResponse<T> = T extends { response: infer Response }
? Response
: never;
type Endpoint = {
method: "GET";
response: {
id: number;
name: string;
};
};
type Response = EndpointResponse<Endpoint>;
// { id: number; name: string }Цей підхід зручний для контрактів API, де кожен маршрут описується власним типом.
Опишімо кілька маршрутів:
type User = {
id: number;
name: string;
email: string;
};
type Product = {
id: number;
title: string;
price: number;
};
type ApiContract = {
"/users/:id": {
method: "GET";
response: User;
};
"/products": {
method: "GET";
response: Product[];
};
"/users": {
method: "POST";
response: User;
};
};Тепер за допомогою mapped type можна отримати карту відповідей:
type EndpointResponse<T> =
T extends { response: infer Response }
? Response
: never;
type ApiResponses<Contract> = {
[Path in keyof Contract]: EndpointResponse<Contract[Path]>;
};
type Responses = ApiResponses<ApiContract>;Responses матиме такий тип:
type Responses = {
"/users/:id": User;
"/products": Product[];
"/users": User;
};Можна описати й тип клієнта API:
type ApiClient<Contract> = {
[Path in keyof Contract]:
Contract[Path] extends { response: infer Response }
? (body?: unknown) => Promise<Response>
: never;
};
type Client = ApiClient<ApiContract>;Результат:
type Client = {
"/users/:id": (body?: unknown) => Promise<User>;
"/products": (body?: unknown) => Promise<Product[]>;
"/users": (body?: unknown) => Promise<User>;
};Важливо, що тип відповіді не дублюється в типі клієнта. Він автоматично виводиться з ApiContract.
Практична схема валідатора може мати метод parse, який приймає невідоме значення та повертає перевірене значення:
interface Validator<T> {
parse(input: unknown): T;
}Тепер створімо тип, який дістає результат валідатора:
type Infer<T> = T extends Validator<infer Result>
? Result
: never;Наприклад:
type StringValidator = Validator<string>;
type ParsedString = Infer<StringValidator>;
// stringДля об’єктів потрібно пройти по всіх ключах схеми:
type Schema = Record<string, Validator<any>>;
type InferObject<S extends Schema> = {
[K in keyof S]: Infer<S[K]>;
};Якщо схема має вигляд:
type UserSchema = {
id: Validator<number>;
name: Validator<string>;
};то:
type ParsedUser = InferObject<UserSchema>;
// {
// id: number;
// name: string;
// }Отже, схема валідатора може бути єдиним джерелом правди для типу даних.
Нижче наведено повний приклад без зовнішніх бібліотек. Він:
створює прості валідатори;
виводить тип об’єкта зі схеми;
використовує infer для результату валідатора;
будує API-контракт;
вибирає числові поля;
створює рекурсивні DeepPartial і DeepReadonly;
виконується під час запуску та перевіряється компілятором.
interface Validator<T> {
parse(input: unknown): T;
}
type AnyValidator = Validator<any>;
type Schema = Record<string, AnyValidator>;
type Infer<T> = T extends Validator<infer Result>
? Result
: never;
type InferObject<S extends Schema> = {
[K in keyof S]: Infer<S[K]>;
};
const v = {
string(): Validator<string> {
return {
parse(input: unknown): string {
if (typeof input !== "string") {
throw new Error("Очікувалося рядкове значення");
}
return input;
},
};
},
number(): Validator<number> {
return {
parse(input: unknown): number {
if (typeof input !== "number" || Number.isNaN(input)) {
throw new Error("Очікувалося числове значення");
}
return input;
},
};
},
boolean(): Validator<boolean> {
return {
parse(input: unknown): boolean {
if (typeof input !== "boolean") {
throw new Error("Очікувалося логічне значення");
}
return input;
},
};
},
array<T>(itemValidator: Validator<T>): Validator<T[]> {
return {
parse(input: unknown): T[] {
if (!Array.isArray(input)) {
throw new Error("Очікувався масив");
}
return input.map((item) => itemValidator.parse(item));
},
};
},
object<S extends Schema>(shape: S): Validator<InferObject<S>> {
return {
parse(input: unknown): InferObject<S> {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new Error("Очікувався об'єкт");
}
const source = input as Record<string, unknown>;
const result: Record<string, unknown> = {};
for (const key of Object.keys(shape)) {
result[key] = shape[key].parse(source[key]);
}
return result as InferObject<S>;
},
};
},
};
const userSchema = v.object({
id: v.number(),
name: v.string(),
email: v.string(),
tags: v.array(v.string()),
active: v.boolean(),
});
type User = Infer<typeof userSchema>;
const parsedUser = userSchema.parse({
id: 1,
name: "Олена",
email: "olena@example.com",
tags: ["admin", "editor"],
active: true,
});
console.log(parsedUser.name.toUpperCase());
console.log(parsedUser.tags.length);
type PickByValue<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};
type NumericFields<T> = PickByValue<T, number>;
type UserNumericFields = NumericFields<User>;
// {
// id: number;
// }
type DeepPartial<T> =
T extends (...args: any[]) => any
? T
: T extends readonly (infer Item)[]
? readonly DeepPartial<Item>[]
: T extends object
? {
[K in keyof T]?: DeepPartial<T[K]>;
}
: T;
type DeepReadonly<T> =
T extends (...args: any[]) => any
? T
: T extends readonly (infer Item)[]
? readonly DeepReadonly<Item>[]
: T extends object
? {
readonly [K in keyof T]: DeepReadonly<T[K]>;
}
: T;
type UserPatch = DeepPartial<Omit<User, "id">>;
const patch: UserPatch = {
name: "Нове ім'я",
tags: ["reviewed"],
};
type EndpointResponse<T> =
T extends { response: infer Response }
? Response
: never;
type ApiContract = {
"/users/:id": {
method: "GET";
response: User;
};
"/users": {
method: "POST";
response: User;
};
};
type ApiResponses<Contract> = {
[Path in keyof Contract]: EndpointResponse<Contract[Path]>;
};
type Responses = ApiResponses<ApiContract>;
type ApiClient<Contract> = {
[Path in keyof Contract]:
Contract[Path] extends { response: infer Response }
? (body?: unknown) => Promise<Response>
: never;
};
type Client = ApiClient<ApiContract>;UserТип User не описаний вручну. Він отриманий із валідатора:
type User = Infer<typeof userSchema>;TypeScript виконує такі кроки:
typeof userSchema — це Validator<InferObject<...>>.
Infer<T> перевіряє, чи є T типом Validator<infer Result>.
infer Result дістає тип результату parse.
InferObject<S> проходить по ключах схеми.
Для кожного валідатора викликається Infer<S[K]>.
У результаті схема:
const userSchema = v.object({
id: v.number(),
name: v.string(),
email: v.string(),
tags: v.array(v.string()),
active: v.boolean(),
});автоматично створює тип:
type User = {
id: number;
name: string;
email: string;
tags: string[];
active: boolean;
};Якщо додати поле до схеми, воно одразу з’явиться в типі User. Якщо змінити валідатор, зміниться і тип.
Рекурсивний тип посилається сам на себе. Він потрібен для структур довільної глибини.
DeepPartialВбудований Partial<T> робить необов’язковими лише властивості верхнього рівня:
type UserPatch = Partial<User>;Для вкладених об’єктів цього недостатньо:
type Profile = {
user: {
name: string;
address: {
city: string;
};
};
};Partial<Profile> дозволить не вказати user, але якщо user присутній, його вкладені поля залишаться обов’язковими.
Рекурсивний варіант:
type DeepPartial<T> =
T extends (...args: any[]) => any
? T
: T extends readonly (infer Item)[]
? readonly DeepPartial<Item>[]
: T extends object
? {
[K in keyof T]?: DeepPartial<T[K]>;
}
: T;Логіка така:
Функції залишаються без змін.
Для масивів рекурсивно обробляється тип елемента.
Для об’єктів кожна властивість стає необов’язковою.
Для примітивів тип повертається без змін.
Тепер допустимий частковий об’єкт:
type ProfilePatch = DeepPartial<Profile>;
const profilePatch: ProfilePatch = {
user: {
address: {
city: "Львів",
},
},
};DeepReadonlyЗа тим самим принципом можна зробити всі вкладені властивості доступними лише для читання:
type DeepReadonly<T> =
T extends (...args: any[]) => any
? T
: T extends readonly (infer Item)[]
? readonly DeepReadonly<Item>[]
: T extends object
? {
readonly [K in keyof T]: DeepReadonly<T[K]>;
}
: T;Приклад:
type ReadonlyProfile = DeepReadonly<Profile>;
declare const profile: ReadonlyProfile;
// profile.user.address.city = "Київ";
// Помилка: властивість доступна лише для читанняПеревірка функцій є важливою: без неї функція могла б бути помилково оброблена як звичайний об’єкт із властивостями.
Умовний тип, який працює з параметром-уніоном, може застосовуватися до кожного елемента окремо:
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// string[] | number[]Це називається distributive conditional type.
Якщо потрібно обробити весь union як одне значення, параметр обгортають у кортеж:
type ToArrayAsWhole<T> = [T] extends [unknown] ? T[] : never;
type Result = ToArrayAsWhole<string | number>;
// (string | number)[]Різниця важлива під час написання універсальних типів для API та трансформацій, де union може означати різні варіанти відповіді.
neverФільтрацію ключів зручно будувати через key remapping:
type KeysOfType<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};
type FormData = {
username: string;
age: number;
subscribed: boolean;
};
type BooleanFields = KeysOfType<FormData, boolean>;
// {
// subscribed: boolean;
// }Якщо потрібен лише union ключів, можна додати індексацію:
type KeyUnionOfType<T, ValueType> = {
[K in keyof T]: T[K] extends ValueType ? K : never;
}[keyof T];
type FormBooleanKey = KeyUnionOfType<FormData, boolean>;
// "subscribed"Ці два варіанти мають різне призначення:
KeysOfType повертає об’єкт із відібраними властивостями;
KeyUnionOfType повертає union ключів.
Типи на рівні типів не перевіряють значення під час виконання. Наприклад:
type User = {
id: number;
name: string;
};Цей тип не перевірить дані, отримані через HTTP або з JSON. Для цього потрібна runtime-перевірка, як у методі parse у прикладі з валідаторами.
Найкращий результат дає поєднання:
runtime-валідатора для фактичної перевірки даних;
infer для виведення типу з валідатора;
mapped types для трансформації отриманого типу;
умовних типів для вибору потрібних варіантів;
рекурсивних типів для вкладених структур.
Типи гарантують безпеку під час компіляції, а валідатори — коректність даних під час виконання.
anyany вимикає значну частину перевірок TypeScript:
type Unsafe = {
value: any;
};У внутрішній реалізації універсального валідатора any іноді використовують для спрощення узагальнення, але в публічних типах краще застосовувати точніші типи або unknown.
unknown змушує спочатку перевірити значення:
function printValue(value: unknown): void {
if (typeof value === "string") {
console.log(value.toUpperCase());
}
}keyof T і T[keyof T]type User = {
id: number;
name: string;
};
type Keys = keyof User;
// "id" | "name"
type Values = User[keyof User];
// number | stringkeyof T — ключі;
T[keyof T] — типи значень усіх властивостей.
neverПід час фільтрації ключів потрібно повертати never для властивостей, які слід вилучити:
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};Якщо замість never повернути інший ключ або K, властивість не буде відфільтрована.
Масиви є об’єктами в термінах TypeScript. Тому такий тип:
type IncorrectDeepPartial<T> =
T extends object
? { [K in keyof T]?: IncorrectDeepPartial<T[K]> }
: T;може обробляти масиви не так, як очікується, перетворюючи їхні службові властивості та індекси.
Для рекурсивних типів спочатку обробляйте масиви:
type CorrectDeepPartial<T> =
T extends readonly (infer Item)[]
? readonly CorrectDeepPartial<Item>[]
: T extends object
? { [K in keyof T]?: CorrectDeepPartial<T[K]> }
: T;Рекурсивний тип повинен мати умову, у якій рекурсія припиняється. Для DeepPartial таким випадком є примітив:
: T;Без базового випадку тип може стати некоректним або призвести до помилки надмірної глибини інстанціювання.
Mapped type, infer і умовні типи зникають після компіляції. Вони не виконують перетворення даних у JavaScript.
Наприклад, DeepReadonly<User> не заморожує об’єкт у runtime. Він лише забороняє присвоєння через TypeScript.
keyof T отримує union ключів типу T.
T[K] отримує тип властивості за ключем.
Mapped types створюють новий тип, проходячи по ключах іншого типу.
Key remapping через as дає змогу перейменовувати та фільтрувати ключі.
Умовні типи вибирають результат залежно від відповідності типу.
infer дістає вкладений тип, наприклад тип результату валідатора або відповіді API.
Рекурсивні типи обробляють об’єкти та масиви довільної глибини.
API-контракт може бути єдиним джерелом типів відповідей і клієнта.
Схема валідатора може одночасно виконувати runtime-перевірку та породжувати статичний тип.
Типи перевіряють код під час компіляції, але для зовнішніх даних усе одно потрібна runtime-валідація.