Злиття декларацій у TypeScript
Злиття декларацій (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
Швидкий приклад
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, JestMatchers - Змінні середовища: додавай специфічні ключі в
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:
// 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:
// Цей файл має import, тобто він є модулем
import express from "express";
// ✅ declare global загортає розширення правильно
declare global {
namespace Express {
interface Request {
userId: string;
}
}
}Без declare global компілятор вважає namespace локальним для модуля і розширення нікуди не застосовується. Це одна з найпоширеніших помилок з цим патерном.
Злиття просторів імен (namespace merging)
Namespace об'єднуються як interface, але функції з однаковим ім'ям у різних блоках повинні мати сумісні сигнатури:
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) в одному блоці:
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
type Point = { x: number };
type Point = { y: number }; // ❌ Duplicate identifier 'Point'Type alias за природою закритий. Перейди на interface або використай перетин: type Point = { x: number } & { y: number }.
Несумісні типи однієї властивості
interface Box {
width: number;
}
interface Box {
width: string; // ❌ Subsequent property declarations must have the same type
}Компілятор вимагає точного збігу типів для однієї властивості в різних деклараціях. Якщо потрібні різні форми, використовуй intersection types.
Забути declare global в ES-модулях
// Цей файл має 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
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 між файлами без одного великого файлу типів, який редагують усі:
// 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
// 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 з перевантаженнями: помилка та її виправлення
// ❌ Неправильно: окремі декларації з несумісними сигнатурами
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) зливаються без проблем.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.