Опануйте рекурсивні типи TypeScript. Цей посібник покаже, як моделювати складні, вкладені структури даних, такі як дерева та JSON, на практичних прикладах.
Опанування рекурсивних типів TypeScript: Глибоке занурення в самореферентні визначення
\n\nУ світі розробки програмного забезпечення ми часто стикаємося зі структурами даних, які є природно вкладеними або ієрархічними. Подумайте про файлові системи, організаційні діаграми, коментарі на платформах соціальних медіа або саму структуру об'єкта JSON. Як ми можемо представити ці складні, самореферентні структури типобезпечним способом? Відповідь полягає в одній з найпотужніших функцій TypeScript: рекурсивних типах.
\n\nЦей вичерпний посібник проведе вас від фундаментальних концепцій рекурсивних типів до розширених застосувань та найкращих практик. Незалежно від того, чи ви досвідчений розробник TypeScript, який прагне поглибити своє розуміння, чи програміст середнього рівня, який бажає вирішити більш складні завдання моделювання даних, ця стаття озброїть вас знаннями для впевненого та точного використання рекурсивних типів.
\n\nЩо таке рекурсивні типи? Сила самореференції
\n\nПо суті, рекурсивний тип — це визначення типу, яке посилається на самого себе. Це еквівалент рекурсивної функції в системі типів — функції, яка викликає саму себе. Ця можливість самопосилання дозволяє нам визначати типи для структур даних, які мають довільну або невідому глибину.
\n\nПроста реальна аналогія — це концепція російської матрьошки. Кожна лялька містить меншу, ідентичну ляльку, яка, в свою чергу, містить ще одну, і так далі. Рекурсивний тип може ідеально змоделювати це: a `Doll` — це тип, який має такі властивості, як `color` та `size`, а також містить необов'язкову властивість, яка є іншою `Doll`.
\n\nБез рекурсивних типів ми були б змушені використовувати менш безпечні альтернативи, такі як `any` або `unknown`, або намагатися визначити кінцеве число рівнів вкладеності (наприклад, `Category`, `SubCategory`, `SubSubCategory`), що є ненадійним і призводить до збою, як тільки потрібен новий рівень вкладеності. Рекурсивні типи пропонують елегантне, масштабоване та типобезпечне рішення.
\n\nВизначення базового рекурсивного типу: Зв'язаний список
\n\nПочнемо з класичної структури даних комп'ютерної науки: зв'язаного списку. Зв'язаний список — це послідовність вузлів, де кожен вузол містить значення та посилання на наступний вузол у послідовності. Останній вузол вказує на `null` або `undefined`, сигналізуючи про кінець списку.
\n\nЦя структура за своєю природою є рекурсивною. `Node` визначається через самого себе. Ось як ми можемо змоделювати це в TypeScript:
\n\n
\ninterface LinkedListNode {\n value: number;\n next: LinkedListNode | null;\n}\n
У цьому прикладі інтерфейс `LinkedListNode` має дві властивості:
\n- \n
- `value`: У цьому випадку, `number`. Пізніше ми зробимо його узагальненим. \n
- `next`: Це рекурсивна частина. Властивість `next` є або іншим `LinkedListNode`, або `null`, якщо це кінець списку. \n
Посилаючись на самого себе у власному визначенні, `LinkedListNode` може описувати ланцюжок вузлів будь-якої довжини. Розглянемо це на практиці:
\n\n
\nconst node3: LinkedListNode = { value: 3, next: null };\nconst node2: LinkedListNode = { value: 2, next: node3 };\nconst node1: LinkedListNode = { value: 1, next: node2 };\n\n// node1 is the head of the list: 1 -> 2 -> 3 -> null\n\nfunction sumLinkedList(node: LinkedListNode | null): number {\n if (node === null) {\n return 0;\n }\n return node.value + sumLinkedList(node.next);\n}\n\nconsole.log(sumLinkedList(node1)); // Outputs: 6\n
Функція `sumLinkedList` є ідеальним доповненням до нашого рекурсивного типу. Це рекурсивна функція, яка обробляє рекурсивну структуру даних. TypeScript розуміє форму `LinkedListNode` і забезпечує повне автозавершення та перевірку типів, запобігаючи поширеним помилкам, таким як спроба доступу до `node.next.value`, коли `node.next` може бути `null`.
\n\nМоделювання ієрархічних даних: Деревовидна структура
\n\nХоча зв'язані списки є лінійними, багато реальних наборів даних є ієрархічними. Тут на допомогу приходять деревовидні структури, а рекурсивні типи є природним способом їх моделювання.
\n\nПриклад 1: Організаційна схема відділу
\nРозглянемо організаційну схему, де кожен співробітник має керівника, і керівники також є співробітниками. Співробітник також може керувати командою інших співробітників.
\n\n
\ninterface Employee {\n id: number;\n name: string;\n role: string;\n reports: Employee[]; // The recursive part!\n}\n\nconst ceo: Employee = {\n id: 1,\n name: 'Alina Sterling',\n role: 'CEO',\n reports: [\n {\n id: 2,\n name: 'Ben Carter',\n role: 'CTO',\n reports: [\n {\n id: 4,\n name: 'David Chen',\n role: 'Lead Engineer',\n reports: []\n }\n ]\n },\n {\n id: 3,\n name: 'Carla Rodriguez',\n role: 'CFO',\n reports: []\n }\n ]\n};\n
Тут інтерфейс `Employee` містить властивість `reports`, яка є масивом інших об'єктів `Employee`. Це елегантно моделює всю ієрархію, незалежно від кількості рівнів управління. Ми можемо писати функції для обходу цього дерева, наприклад, щоб знайти конкретного співробітника або розрахувати загальну кількість людей у відділі.
\n\nПриклад 2: Файлова система
\n\nЩе однією класичною деревовидною структурою є файлова система, що складається з файлів та каталогів (папок). Каталог може містити як файли, так і інші каталоги.
\n\n
\ninterface File {\n type: 'file';\n name: string;\n size: number; // in bytes\n}\n\ninterface Directory {\n type: 'directory';\n name: string;\n contents: FileSystemNode[]; // The recursive part!\n}\n\n// A discriminated union for type safety\ntype FileSystemNode = File | Directory;\n\nconst root: Directory = {\n type: 'directory',\n name: 'project',\n contents: [\n {\n type: 'file',\n name: 'package.json',\n size: 256\n },\n {\n type: 'directory',\n name: 'src',\n contents: [\n {\n type: 'file',\n name: 'index.ts',\n size: 1024\n },\n {\n type: 'directory',\n name: 'components',\n contents: []\n }\n ]\n }\n ]\n};\n
У цьому більш складному прикладі ми використовуємо об'єднаний тип `FileSystemNode`, щоб представити, що сутність може бути або `File`, або `Directory`. Інтерфейс `Directory` потім рекурсивно використовує `FileSystemNode` для своїх `contents`. Властивість `type` діє як дискримінант, дозволяючи TypeScript правильно звужувати тип у операторах `if` або `switch`.
\n\nРобота з JSON: Універсальне та практичне застосування
\n\nМожливо, найпоширенішим випадком використання рекурсивних типів у сучасній веб-розробці є моделювання JSON (JavaScript Object Notation). Значення JSON може бути рядком, числом, булевим значенням, null, масивом значень JSON або об'єктом, значення властивостей якого є значеннями JSON.
\n\nПомічаєте рекурсію? Елементи масиву є значеннями JSON. Властивості об'єкта є значеннями JSON. Це вимагає самореферентного визначення типу.
\n\nВизначення типу для довільного JSON
\n\nОсь як можна визначити надійний тип для будь-якої дійсної структури JSON. Цей шаблон неймовірно корисний при роботі з API, які повертають динамічні або непередбачувані дані JSON.
\n\n
\ntype JsonValue =\n | string\n | number\n | boolean\n | null\n | JsonValue[] // Recursive reference to an array of itself\n | { [key: string]: JsonValue }; // Recursive reference to an object of itself\n\n// It's also common to define JsonObject separately for clarity:\ntype JsonObject = { [key: string]: JsonValue };\n\n// And then redefine JsonValue like this:\ntype JsonValue = \n | string\n | number\n | boolean\n | null\n | JsonValue[]\n | JsonObject;\n\n
Це приклад взаємної рекурсії. `JsonValue` визначається через `JsonObject` (або вбудований об'єкт), а `JsonObject` визначається через `JsonValue`. TypeScript елегантно обробляє це циклічне посилання.
\n\nПриклад: Типобезпечна функція JSON Stringify
\n\nЗ нашим типом `JsonValue` ми можемо створювати функції, які гарантовано працюватимуть лише з дійсними JSON-сумісними структурами даних, запобігаючи помилкам під час виконання ще до їх виникнення.
\n\n
\nfunction processJson(data: JsonValue): void {\n if (typeof data === 'string') {\n console.log(`Found a string: ${data}`);\n } else if (Array.isArray(data)) {\n console.log('Processing an array...');\n data.forEach(processJson); // Recursive call\n } else if (typeof data === 'object' && data !== null) {\n console.log('Processing an object...');\n for (const key in data) {\n processJson(data[key]); // Recursive call\n }\n }\n // ... handle other primitive types\n}\n\nconst myData: JsonValue = {\n user: 'Alex',\n is_active: true,\n session_data: {\n id: 12345,\n tokens: ['A', 'B', 'C']\n }\n};\n\nprocessJson(myData);\n
Типізуючи параметр `data` як `JsonValue`, ми гарантуємо, що будь-яка спроба передати функцію, об'єкт `Date`, `undefined` або будь-яке інше несеріалізоване значення до `processJson` призведе до помилки компіляції. Це значне підвищення надійності коду.
\n\nРозширені концепції та потенційні підводні камені
\n\nЗанурюючись глибше в рекурсивні типи, ви зустрінете більш складні шаблони та кілька поширених проблем.
\n\nУзагальнені рекурсивні типи
\n\nНаш початковий `LinkedListNode` був жорстко закодований для використання `number` як свого значення. Це не дуже зручно для повторного використання. Ми можемо зробити його узагальненим для підтримки будь-якого типу даних.
\n\n
\ninterface GenericNode<T> {\n value: T;\n next: GenericNode<T> | null;\n}\n\nlet stringNode: GenericNode<string> = { value: 'hello', next: null };\nlet numberNode: GenericNode<number> = { value: 123, next: null };\n\ninterface User { id: number; name: string; }\nlet userNode: GenericNode<User> = { value: { id: 1, name: 'Alex' }, next: null };\n
Ввівши параметр типу `
Страхітлива помилка: "Type instantiation is excessively deep and possibly infinite"
\n\nІноді, при визначенні особливо складного рекурсивного типу, ви можете зіткнутися з цією сумнозвісною помилкою TypeScript. Це відбувається тому, що компілятор TypeScript має вбудоване обмеження глибини, щоб захистити себе від застрягання в нескінченному циклі під час розв'язання типів. Якщо ваше визначення типу занадто пряме або складне, воно може досягти цієї межі.
\n\nРозглянемо цей проблемний приклад:
\n\n
\n// This can cause issues\ntype BadTuple = [string, BadTuple] | [];\n
Хоча це може здатися дійсним, спосіб розширення псевдонімів типів у TypeScript іноді може призвести до цієї помилки. Одним з найефективніших способів вирішення цієї проблеми є використання `interface`. Інтерфейси створюють іменований тип у системі типів, на який можна посилатися без негайного розширення, що зазвичай обробляє рекурсію більш елегантно.
\n\n
\n// This is much safer\ninterface GoodTuple {\n head: string;\n tail: GoodTuple | null;\n}\n
Якщо ви повинні використовувати псевдонім типу, ви можете іноді розірвати пряму рекурсію, ввівши проміжний тип або використовуючи іншу структуру. Однак, загальне правило: для складних форм об'єктів, особливо рекурсивних, віддавайте перевагу `interface` над `type`.
\n\nРекурсивні умовні та відображені типи
\n\nСправжня сила системи типів TypeScript розкривається, коли ви поєднуєте її можливості. Рекурсивні типи можуть використовуватися в розширених службових типах, таких як відображені та умовні типи, для виконання глибоких перетворень структур об'єктів.
\n\nКласичним прикладом є `DeepReadonly<T>`, який рекурсивно робить кожну властивість об'єкта та його підоб'єктів `readonly`.
\n\n
\ntype DeepReadonly<T> = T extends (...args: any[]) => any\n ? T\n : T extends object\n ? { readonly [P in keyof T]: DeepReadonly<T[P]> }\n : T;\n\ninterface UserProfile {\n id: number;\n details: {\n name: string;\n address: {\n city: string;\n };\n };\n}\n\ntype ReadonlyUserProfile = DeepReadonly<UserProfile>;\n\n// const profile: ReadonlyUserProfile = ...\n// profile.id = 2; // Error!\n// profile.details.name = 'New Name'; // Error!\n// profile.details.address.city = 'New City'; // Error!\n
Давайте розберемо цей потужний службовий тип:
\n- \n
- Спочатку він перевіряє, чи `T` є функцією, і залишає її як є. \n
- Потім він перевіряє, чи `T` є об'єктом. \n
- Якщо це об'єкт, він перебирає кожну властивість `P` в `T`. \n
- Для кожної властивості він застосовує `readonly`, а потім — це ключове — рекурсивно викликає `DeepReadonly` для типу властивості `T[P]`. \n
- Якщо `T` не є об'єктом (тобто, примітивним типом), він повертає `T` як є. \n
Цей шаблон рекурсивної маніпуляції типами є фундаментальним для багатьох розширених бібліотек TypeScript і дозволяє створювати неймовірно надійні та виразні службові типи.
\n\nНайкращі практики використання рекурсивних типів
\n\nЩоб ефективно використовувати рекурсивні типи та підтримувати чисту, зрозумілу кодову базу, враховуйте ці найкращі практики:
\n\n- \n
- Віддавайте перевагу інтерфейсам для публічних API: При визначенні рекурсивного типу, який буде частиною публічного API бібліотеки або спільного модуля, `interface` часто є кращим вибором. Він надійніше обробляє рекурсію та надає кращі повідомлення про помилки. \n
- Використовуйте псевдоніми типів для простіших випадків: Для простих, локальних або рекурсивних типів на основі об'єднань (як у нашому прикладі `JsonValue`) псевдонім `type` цілком прийнятний і часто більш лаконічний. \n
- Документуйте свої структури даних: Складний рекурсивний тип може бути важко зрозуміти з першого погляду. Використовуйте коментарі TSDoc, щоб пояснити структуру, її призначення та навести приклад. \n
- Завжди визначайте базовий випадок: Так само як рекурсивна функція потребує базового випадку для припинення свого виконання, рекурсивний тип потребує способу завершення. Зазвичай це `null`, `undefined` або порожній масив (`[]`), який зупиняє ланцюг самопосилань. У нашому `LinkedListNode` базовим випадком був `| null`. \n
- Використовуйте дискриміновані об'єднання: Коли рекурсивна структура може містити різні типи вузлів (як у нашому прикладі `FileSystemNode` з `File` та `Directory`), використовуйте дискриміноване об'єднання. Це значно покращує безпеку типів при роботі з даними. \n
- Тестуйте свої типи та функції: Пишіть модульні тести для функцій, які споживають або створюють рекурсивні структури даних. Переконайтеся, що ви охопили граничні випадки, такі як порожній список/дерево, структуру з одним вузлом та глибоко вкладену структуру. \n
Висновок: Долаємо складність з елегантністю
\n\nРекурсивні типи — це не просто езотерична функція для авторів бібліотек; це фундаментальний інструмент для будь-якого розробника TypeScript, якому потрібно моделювати реальний світ. Від простих списків до складних JSON-дерев і доменних ієрархічних даних, самореферентні визначення забезпечують план для створення надійних, самодокументованих та типобезпечних програм.
\n\nРозуміючи, як визначати, використовувати та поєднувати рекурсивні типи з іншими розширеними функціями, такими як узагальнення та умовні типи, ви можете підвищити свої навички TypeScript та створювати програмне забезпечення, яке буде більш стійким та легшим для розуміння. Наступного разу, коли ви зіткнетеся з вкладеною структурою даних, у вас буде ідеальний інструмент для її моделювання з елегантністю та точністю.