Пошук уроків, статей та іншого контенту
Описуватимете вкладені структури й рекурсивні перетворення типів із контрольованою глибиною та обмеженнями компілятора.
Рекурсивний тип посилається сам на себе у власному визначенні. Він добре підходить для структур, які можуть містити вкладені екземпляри такого самого типу:
дерево файлів і каталогів;
абстрактне синтаксичне дерево;
вкладені конфігурації;
JSON-подібні дані;
пов’язані списки;
рекурсивні перетворення типів.
Рекурсія на рівні типів не виконується під час роботи програми. TypeScript обчислює типи під час перевірки коду.
Простий приклад — дерево:
type TreeNode<T> = {
value: T;
children: TreeNode<T>[];
};
const tree: TreeNode<string> = {
value: "root",
children: [
{
value: "src",
children: [
{
value: "index.ts",
children: [],
},
],
},
],
};TreeNode<T> містить масив TreeNode<T>, тому дерево може мати необмежену кількість рівнів вкладеності.
Рекурсивні типи також можна будувати через інтерфейси:
interface Directory {
name: string;
files: string[];
directories: Directory[];
}Для структур даних обидва підходи можуть бути доречними. Типові псевдоніми зручні для композиції з умовними та mapped-типами, а інтерфейси часто використовують для об’єктних контрактів.
JSON складається зі значень кількох базових типів, масивів і об’єктів, які знову можуть містити JSON-значення:
type JsonPrimitive = string | number | boolean | null;
type JsonValue =
| JsonPrimitive
| JsonValue[]
| {
[key: string]: JsonValue;
};
const configuration: JsonValue = {
enabled: true,
retries: 3,
tags: ["api", "production"],
metadata: {
owner: "team-a",
archived: null,
},
};Рекурсивний зв’язок тут має такий вигляд:
JsonValue може бути масивом JsonValue;
JsonValue може бути об’єктом, чиї властивості мають тип JsonValue;
вкладеність завершується примітивним значенням або null.
Рекурсивні conditional-типи дають змогу перетворювати кожен рівень вкладеної структури.
Наприклад, простий DeepPartial робить необов’язковими властивості всіх вкладених об’єктів:
type DeepPartial<T> =
T extends object
? {
[K in keyof T]?: DeepPartial<T[K]>;
}
: T;
type UserSettings = {
profile: {
name: string;
avatar: {
url: string;
width: number;
};
};
notifications: {
email: boolean;
push: boolean;
};
};
const patch: DeepPartial<UserSettings> = {
profile: {
avatar: {
url: "/new-avatar.png",
},
},
};Оскільки DeepPartial викликає сам себе для кожної властивості, перетворення застосовується на всіх рівнях.
Проте такий варіант має важливі обмеження:
масиви також є об’єктами;
функції також мають властивості на рівні типів;
дуже глибокі структури можуть спричинити надмірно складні обчислення;
перетворення не має обмеження глибини.
У практичних типах рекурсію часто потрібно зупиняти після певної кількості рівнів.
Для цього можна передавати додатковий параметр Depth і зменшувати його під час кожного рекурсивного виклику.
TypeScript не виконує арифметику над числовими літералами так, як звичайна програма. Тому для невеликого заздалегідь визначеного діапазону можна використати кортеж:
type Previous = [0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
type Decrement<N extends number> =
N extends keyof Previous ? Previous[N] : 0;Відповідність індексів має такий вигляд:
type Zero = Decrement<0>; // 0
type Three = Decrement<4>; // 3
type Ten = Decrement<11>; // 10Значення за межами таблиці перетворюються на 0. Це обмежує рекурсію та не дає їй продовжуватися необмежено.
DeepReadonly з обмеженою глибиноюtype Primitive =
| string
| number
| boolean
| bigint
| symbol
| null
| undefined;
type Previous = [0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
type Decrement<N extends number> =
N extends keyof Previous ? Previous[N] : 0;
type DeepReadonly<T, Depth extends number = 3> =
Depth extends 0
? T
: T extends Primitive
? T
: T extends (...args: never[]) => unknown
? T
: T extends readonly (infer Item)[]
? readonly DeepReadonly<Item, Decrement<Depth>>[]
: T extends object
? {
readonly [K in keyof T]: DeepReadonly<T[K], Decrement<Depth>>;
}
: T;
type ApplicationState = {
user: {
profile: {
name: string;
preferences: {
theme: "light" | "dark";
};
};
};
logs: string[];
};
type ReadonlyState = DeepReadonly<ApplicationState, 2>;
const state: ReadonlyState = {
user: {
profile: {
name: "Olena",
preferences: {
theme: "dark",
},
},
},
logs: ["started"],
};
state.user.profile = {
name: "Ihor",
preferences: {
theme: "light",
},
};
// Помилка: перший рівень після кореня доступний лише для читання.
// state.user = state.user;У цьому прикладі:
Depth extends 0 зупиняє рекурсію;
примітиви повертаються без змін;
функції не перетворюються на об’єкти;
масиви перетворюються на масиви лише для читання;
звичайні об’єкти проходять через mapped-тип;
для кожного вкладеного рівня використовується Decrement<Depth>.
Важливо визначити, що саме означає глибина у вашому типі. У наведеному прикладі Depth = 0 означає «не застосовувати більше перетворень», а не «заборонити доступ до значення».
Ще один поширений сценарій — отримати об’єднання рядків із можливими шляхами до властивостей вкладеного об’єкта.
type Previous = [0, 0, 1, 2, 3, 4, 5, 6];
type Decrement<N extends number> =
N extends keyof Previous ? Previous[N] : 0;
type StringKey<T> = Extract<keyof T, string>;
type ObjectPaths<T, Depth extends number = 3> =
Depth extends 0
? never
: T extends object
? {
[K in StringKey<T>]:
T[K] extends object
? K | `${K}.${ObjectPaths<T[K], Decrement<Depth>>}`
: K;
}[StringKey<T>]
: never;
type Settings = {
server: {
host: string;
port: number;
security: {
enabled: boolean;
};
};
debug: boolean;
};
type SettingsPath = ObjectPaths<Settings, 3>;
const path1: SettingsPath = "server";
const path2: SettingsPath = "server.host";
const path3: SettingsPath = "server.security.enabled";
const path4: SettingsPath = "debug";
// Помилка: такої властивості немає в Settings.
// const invalidPath: SettingsPath = "server.database";Розглянемо внутрішню логіку:
Беруться лише ключі, які є рядками.
Кожен ключ додається як самостійний шлях.
Якщо значення властивості є об’єктом, для нього рекурсивно будуються вкладені шляхи.
Шляхи об’єднуються через шаблонний літеральний тип ${K}.${...}.
Параметр Depth обмежує максимальну кількість рекурсивних переходів.
Такий тип є спрощеним: він розглядає масиви як об’єкти з ключами та не розрізняє всі можливі спеціальні типи. Перед використанням у бібліотеці його потрібно адаптувати до конкретної моделі даних.
Кожен рекурсивний тип повинен мати умову зупинки. Найчастіше використовують один із варіантів:
type Wrap<T> =
T extends object
? { value: Wrap<T> }
: T;Примітивні типи не розгортаються далі.
type LimitedWrap<T, Depth extends number> =
Depth extends 0
? T
: { value: LimitedWrap<T, Decrement<Depth>> };Цей підхід дає контроль над максимальною кількістю рівнів.
Функції, дати, регулярні вирази та екземпляри класів технічно є об’єктами. Якщо застосовувати до них загальне рекурсивне перетворення, результат може бути неочікуваним. Тому такі типи часто виключають окремою умовою:
type BuiltIn =
| string
| number
| boolean
| bigint
| symbol
| null
| undefined
| Date
| RegExp
| Function;Після цього умовний тип може повертати T без перетворення, якщо T extends BuiltIn.
Рекурсивні типи обчислюються компілятором. Якщо обчислення стає надто глибоким або складним, TypeScript може повідомити про надмірну глибину інстанціювання типу, зокрема помилку TS2589.
Причини можуть бути такими:
відсутній базовий випадок;
параметр глибини не зменшується;
тип містить багато розгалужень;
рекурсія застосовується до великих об’єднань;
об’єктні типи обробляються без виключення масивів, функцій або спеціальних класів;
рекурсивні типи комбінуються між собою кілька разів.
Контрольована глибина не усуває всі обмеження компілятора, але істотно зменшує ризик надмірного обчислення.
Використовуйте невеликий ліміт глибини за замовчуванням.
Завершуйте рекурсію через Depth extends 0.
Не використовуйте number як глибину без потреби: конкретні літеральні числа обчислюються передбачуваніше.
Обробляйте примітиви до перевірки T extends object.
Окремо вирішуйте, як мають оброблятися масиви та функції.
Не додавайте рекурсивність до типу лише тому, що це можливо.
Перевіряйте складні типи на реальних моделях, а не тільки на маленьких прикладах.
type Broken<T> = {
value: Broken<T>;
};Такий тип не має природної умови завершення. Він описує структуру, яка завжди повинна містити ще один рівень value.
type BrokenDepth<T, Depth extends number> =
Depth extends 0
? T
: T extends object
? { [K in keyof T]: BrokenDepth<T[K], Depth> }
: T;Параметр Depth тут не зменшується. Якщо тип потрапить у гілку об’єкта, умова завершення не буде досягнута.
Правильний варіант має передавати менше значення:
type CorrectDepth<T, Depth extends number> =
Depth extends 0
? T
: T extends object
? {
[K in keyof T]: CorrectDepth<T[K], Decrement<Depth>>;
}
: T;Функції є об’єктами з точки зору системи типів. Тому тип на кшталт:
type ReadonlyObject<T> = {
readonly [K in keyof T]: ReadonlyObject<T[K]>;
};може перетворити властивості функції замість того, щоб залишити функцію без змін.
Параметр Depth extends number дозволяє передати будь-яке число. Якщо таблиця зменшення підтримує лише обмежений діапазон, непідтримуване число може перетворитися на 0 або породити неочікуваний результат.
Для внутрішніх типів часто краще використовувати документацію та обмежений набір очікуваних значень, наприклад 0 | 1 | 2 | 3 | 4 | 5.
Рекурсивний тип лише описує форму даних або трансформацію типів. Він не створює рекурсивну функцію і не обходить об’єкт під час виконання.
Наприклад:
type NestedValue = {
value: string;
next?: NestedValue;
};
function printValues(node: NestedValue): void {
console.log(node.value);
if (node.next) {
printValues(node.next);
}
}Тут NestedValue є рекурсивним типом, а printValues — окремою рекурсивною функцією під час виконання.
Рекурсивні типи можуть посилатися самі на себе.
Вони описують дерева, JSON, вкладені конфігурації та інші рекурсивні структури.
Conditional-типи та mapped-типи дають змогу рекурсивно змінювати вкладені властивості.
Параметр глибини та тип декременту контролюють кількість рекурсивних кроків.
Базовий випадок має бути явним і повинен досягатися під час кожної гілки рекурсії.
Примітиви, функції, масиви та спеціальні об’єкти потрібно обробляти окремо.
Надмірно глибокі або складні рекурсивні типи можуть перевищити обмеження компілятора.
Контрольована рекурсія забезпечує баланс між точністю типів і швидкістю перевірки коду.