Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Злиття декларацій у TypeScript». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Злиття декларацій (declaration merging) у TypeScript** - це коли компілятор автоматично об'єднує кілька декларацій з однаковим ім'ям в одне визначення. Interface підтримує це, type alias - ні. ```typescript interface User { name: string; } interface User { age: number; } // Результат: { name: string; age: number } ``` **Ключове:** Interface для розширюваних типів, type - для фіксованих структур і union.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Злиття декларацій (declaration merging)** у TypeScript - це механізм, коли компілятор автоматично об'єднує кілька декларацій з однаковим ім'ям в одне визначення. Підтримують це interface, простори імен (namespace) та enum. Type alias - ні. ## Теорія ### TL;DR - `interface User {}`, оголошений двічі, об'єднується автоматично; `type User = {}` двічі - помилка "Duplicate identifier" - Уявляй interface як спільний Google Doc, до якого можна дописувати; type - як PDF, зафіксований і закритий - Для публічних API і типів бібліотек - interface. Для union, primitives і фіксованих структур - type - Module augmentation (розширення сторонніх типів, як-от Express або Jest) повністю побудоване на цьому механізмі - Жодних витрат у runtime: усі типи стираються перед компіляцією в JavaScript ### Швидкий приклад ```typescript interface User { name: string; } interface User { age: number; } interface User { email: string; } // TypeScript бачить один об'єднаний тип: // { name: string; age: number; email: string } const user: User = { name: "Alice", age: 25, email: "alice@example.com" }; // ✅ // А ось це падає одразу: // type User = { name: string }; // type User = { age: number }; // ❌ Duplicate identifier 'User' ``` Три окремі декларації стають однією. Компілятор вимагає всі поля разом. ### Interface vs type alias: чому одне зливається, а інше - ні Interface генерує структурний символ, який компілятор може перетинати між файлами. Type alias - це закрите ім'я без логіки злиття. Це свідоме архітектурне рішення: interface за природою відкритий і розширюваний, а type alias описує фінальну, закриту структуру. Для бібліотечного коду різниця принципова. Якщо публікуєш interface, споживачі можуть його розширити без змін у твоєму коді. Якщо type alias - не можуть. Багато команд за замовчуванням використовують interface для об'єктних структур, інші обирають type скрізь. Обидва підходи працюють. Вибір залежить від того, чи потрібне розширення (interface) або композиція (type). ### Коли застосовувати злиття декларацій - **Бібліотека або SDK**: декларуй props як interface, щоб споживачі могли їх розширити без форку пакету - **Module augmentation**: розширення сторонніх типів - Express `Request`, Jest `Matchers` - **Змінні середовища**: додавай специфічні ключі в `NodeJS.ProcessEnv` - **Простори імен**: поділяй великий namespace між файлами, зберігаючи єдиний доступ - **Union-типи, primitives, фіксовані структури**: тут type більш доречний, бо закрита форма і є ціль ### Правила злиття | Декларація | Може об'єднуватися з | |---|---| | `interface` | `interface` | | `namespace` | `namespace`, `class`, `function`, `enum` | | `class` | `namespace` | | `function` | `namespace` | | `enum` | `enum`, `namespace` | | `type` alias | нічим | Enum можна злити з іншим enum, але кожне значення повинно мати унікальний ключ. При конфліктах - помилка компілятора. ### Як компілятор обробляє злиття TypeScript сканує всі `.ts` файли в компіляційній одиниці, збирає декларації за точним ім'ям (з урахуванням регістру) і об'єднує сумісні в єдиний запис таблиці символів під час семантичного аналізу. Для interface компілятор перетинає типи членів: якщо дві декларації визначають одну властивість з несумісними типами, помилка виникає в точці конфлікту. Витрат у runtime немає - усі типи стираються до JavaScript. ### Module augmentation Саме тут злиття декларацій найбільш практично корисне. Класичний приклад - Express: ```typescript // types/express.d.ts (немає import/export = ambient script = глобальна область) declare namespace Express { interface Request { user?: { id: string; role: "admin" | "editor" | "viewer"; }; } } // TypeScript знає про req.user в кожному обробнику app.get("/profile", (req, res) => { console.log(req.user?.id); // ✅ повністю типізовано }); ``` У файлах-модулях (де є `import` або `export`) для глобального розширення потрібен `declare global`: ```typescript // Цей файл має import, тобто він є модулем import express from "express"; // ✅ declare global загортає розширення правильно declare global { namespace Express { interface Request { userId: string; } } } ``` Без `declare global` компілятор вважає namespace локальним для модуля і розширення нікуди не застосовується. Це одна з найпоширеніших помилок з цим патерном. ### Злиття просторів імен (namespace merging) Namespace об'єднуються як interface, але функції з однаковим ім'ям у різних блоках повинні мати сумісні сигнатури: ```typescript namespace MyLib { export function log(msg: string): void { console.log("v1:", msg); } } namespace MyLib { // ❌ Помилка: несумісна сигнатура з попередньою декларацією export function log(msg: string, level?: number): void { console.log("v2:", msg, level); } } ``` Рішення: оголошуй всі перевантаження (overloads) в одному блоці: ```typescript namespace MyLib { export function log(msg: string): void; export function log(msg: string, level: number): void; export function log(msg: string, level?: number): void { console.log(msg, level); } } ``` Цей патерн часто зустрічається в `@types/node`, зокрема для `fs.promises`. ### Типові помилки **Очікувати, що `type` зливається як `interface`** ```typescript type Point = { x: number }; type Point = { y: number }; // ❌ Duplicate identifier 'Point' ``` Type alias за природою закритий. Перейди на interface або використай перетин: `type Point = { x: number } & { y: number }`. **Несумісні типи однієї властивості** ```typescript interface Box { width: number; } interface Box { width: string; // ❌ Subsequent property declarations must have the same type } ``` Компілятор вимагає точного збігу типів для однієї властивості в різних деклараціях. Якщо потрібні різні форми, використовуй intersection types. **Забути `declare global` в ES-модулях** ```typescript // Цей файл має import - він є модулем import express from "express"; // ❌ Розширення не застосується глобально - namespace локальний declare module "express" { interface Request { userId: string; } } // ✅ Ось так правильно declare global { namespace Express { interface Request { userId: string; } } } ``` Файл `.ts` без жодного import або export - це ambient script, він діє глобально. Додай будь-який import і він стає модулем. **Конфлікт значень в enum** ```typescript enum Status { Active = 1, } enum Status { Active = 2, // ❌ Redeclaration of 'Active' with a different value } ``` Кожна декларація в злитому enum повинна мати унікальні ключі. ### Де зустрічається в реальних проєктах - **DefinitelyTyped (@types/react)**: зливає `React.JSX.IntrinsicElements` для атрибутів кастомних елементів - **Express middleware**: `declare global { namespace Express { interface Request { user?: User } } }` дає типізований `req.user` в усьому застосунку - **Node.js type shims**: розширення namespace `global` для поліфілів типу `process.browser` - **Jest custom matchers**: `declare module "@jest/expect" { interface Matchers<R> { toBeWithinRange(a: number, b: number): R } }` - **Vite env types**: `interface ImportMetaEnv` розширюється у `vite-env.d.ts` для типізованого `import.meta.env` Одна з типових помилок у командах: файл розширення має `import` на початку, стає модулем, і декларація без `declare global` залишається локальною. Люди витрачають годину на дебаг, поки не зрозуміють що справа не в логіці, а у структурі файлу. ### Питання для співбесіди **Q:** Чому `interface` підтримує злиття, а `type` - ні? **A:** Interface генерує структурний символ, який компілятор може перетинати між деклараціями. Type alias - це закрите посилання без логіки злиття. Interface за архітектурою відкритий, type - фінальний. **Q:** Чи можуть класи брати участь у злитті декларацій? **A:** Самі класи між собою не зливаються. Але клас може злитися з namespace - це дозволяє додавати статичні утиліти або фабричні функції без зміни тіла класу. **Q:** Що відбувається, якщо об'єднані interface мають несумісні типи однієї властивості? **A:** Компілятор видає помилку в точці конфліктної декларації, не в точці використання. Повідомлення вказує ім'я властивості і обидва типи. **Q:** Чи має значення порядок файлів при злитті? **A:** Для interface - ні: компілятор сканує всі файли разом і порядок декларацій не впливає на результат. Для перевантажень функцій у namespace порядок сигнатур впливає на resolution. **Q:** Як злиття декларацій працює в ES-модулях vs CommonJS? **A:** Модульна система не впливає на те, як компілятор зливає декларації. Важливо інше: чи є файл ambient (без import/export) чи модулем. У файлах-модулях для глобального розширення потрібен `declare global`. **Q:** (Senior) Як TypeScript 5.x обробляє triple-slash augmentation vs `package.json` `typesVersions`? **A:** Triple-slash директиви для розширення досі підтримуються, але рекомендований підхід для авторів бібліотек - `typesVersions` у `package.json`. Це дозволяє мапити точки входу для типів під конкретні версії TypeScript без засмічення глобального простору директивами. ## Приклади ### Пропси компонента у дизайн-системі, розподілені по файлах Цей патерн показує, як бібліотека компонентів може розподілити декларації props між файлами без одного великого файлу типів, який редагують усі: ```typescript // button.types.ts - базові props interface ButtonProps { variant: "primary" | "secondary"; onClick: () => void; } // button.size.ts - додає розміри без змін у базовому файлі interface ButtonProps { size: "small" | "medium" | "large"; } // button.icon.ts - підтримка іконок (опційна) interface ButtonProps { icon?: string; iconPosition?: "left" | "right"; } // Усі три зливаються. TypeScript вимагає всі обов'язкові поля: function Button({ variant, size, onClick, icon }: ButtonProps) { // ... } // ✅ variant, size, onClick - обов'язкові; icon - опційний Button({ variant: "primary", size: "large", onClick: () => {} }); ``` Кожна команда відповідає за свій файл. Немає конфліктів у Git при злитті. Компілятор збирає повний тип з усіх трьох файлів автоматично. ### Middleware автентифікації Express з типізованим `req.user` ```typescript // src/types/express.d.ts // Немає import/export - це ambient script, діє глобально declare namespace Express { interface Request { user?: { id: string; email: string; role: "admin" | "editor" | "viewer"; }; } } // src/middleware/auth.ts import { Request, Response, NextFunction } from "express"; export function requireAuth(req: Request, res: Response, next: NextFunction) { const token = req.headers.authorization?.split(" ")[1]; if (!token) { return res.status(401).json({ error: "Unauthorized" }); } // Токен перевірено - додаємо user до запиту req.user = { id: "123", email: "user@example.com", role: "admin" }; next(); } // src/routes/profile.ts app.get("/profile", requireAuth, (req, res) => { res.json({ id: req.user?.id, role: req.user?.role }); // ✅ повністю типізовано }); ``` Файл `.d.ts` не має імпортів, тому він ambient і застосовується глобально. Додай `import express from "express"` на початок - і розширення перестане працювати без `declare global { namespace Express { ... } }`. ### Злиття namespace з перевантаженнями: помилка та її виправлення ```typescript // ❌ Неправильно: окремі декларації з несумісними сигнатурами namespace Logger { export function log(msg: string): void { console.log(msg); } } namespace Logger { // Помилка: Property 'log' is incompatible between declarations export function log(msg: string, level: "info" | "warn"): void { console.log(`[${level}]`, msg); } } // ✅ Правильно: усі overloads в одному блоці namespace Logger { export function log(msg: string): void; export function log(msg: string, level: "info" | "warn"): void; export function log(msg: string, level?: "info" | "warn"): void { if (level) { console.log(`[${level}]`, msg); } else { console.log(msg); } } export interface Config { prefix?: string; defaultLevel: "info" | "warn"; } } // Другий блок namespace може додавати нові члени без конфліктів namespace Logger { export function createPrefixed(prefix: string): (msg: string) => void { return (msg) => Logger.log(`${prefix} ${msg}`); } } Logger.log("Server started"); // ✅ Logger.log("Auth failed", "warn"); // ✅ const apiLog = Logger.createPrefixed("API"); // ✅ apiLog("Request received"); // ✅ виводить: API Request received ``` Компілятор видає помилку при конфлікті імен функцій між блоками namespace, бо не може визначити яку реалізацію використати. Члени без конфліктів (`createPrefixed`, `Config`) зливаються без проблем.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.