Відмінності між type та interface в TypeScript
type та interface у TypeScript обидва описують форму об'єктів, але поводяться по-різному: interface автоматично об'єднує повторні декларації, а type створює фіксований псевдонім, що підтримує union-типи, примітиви і mapped types.
Теорія
TL;DR
interface= Google Doc (можна дописати будь-коли);type= PDF (зафіксований після створення)- Головна різниця:
interfaceавтоматично об'єднує повторні декларації,typeдає помилку при повторному оголошенні - Тільки
typeпідтримує union-типи (A | B), примітиви, кортежі та mapped types - Потрібні контракти об'єктів, наслідування або аугментація бібліотек?
interface. Union, discriminated union або трансформації типів?type - Обидва повністю видаляються під час компіляції. В рантаймі їх немає
Швидкий приклад
// interface: повторні декларації об'єднуються
interface User {
name: string;
}
interface User {
age: number; // об'єднується з декларацією вище
}
// Результат: User = { name: string; age: number }
// type: повторна декларація - помилка
type Product = { id: number };
// type Product = { name: string }; // ПОМИЛКА: Duplicate identifier 'Product'
// Тільки type підтримує union-типи
type Status = "active" | "inactive" | "pending";
type Result = User | Product;Оголоси User двічі через interface - TypeScript об'єднає їх в один тип. Зроби те саме з type - компілятор відмовить.
Головна різниця
interface призначений для опису форми об'єктів. Він підтримує злиття декларацій (declaration merging), коли дві декларації з однаковою назвою об'єднуються в одну, і наслідування через extends. type - загальний псевдонім для будь-якої структури: примітиви, union-типи, перетини (intersection), кортежі. Але його не можна повторно оголосити чи злити. Після type X = ... це визначення фінальне.
Коли що використовувати
interface: контракти об'єктів для класів (implements), форми API-відповідей, React props, які інші компоненти можуть розширювати, аугментація бібліотекtype: union-типи ("success" | "error"), перетини (A & B), псевдоніми примітивів (type ID = string | number), discriminated union для стейт-машин і reducers, mapped types, conditional types
Багато команд за замовчуванням беруть interface для об'єктів і type для всього іншого. Інші використовують type скрізь. Обидва підходи працюють. Найбільший головний біль, який я бачив: хтось публікує бібліотеку з type для публічних контрактів, які споживачі мали б розширювати. Такий вибір відгукується в кожному downstream-проекті.
Таблиця порівняння
| Можливість | interface | type |
|---|---|---|
| Форма об'єкта | Так | Так |
| Union-типи | Ні | Так |
| Перетин | Через extends | Через & |
| Злиття декларацій | Так | Ні |
| Примітиви / кортежі | Ні | Так |
| Сигнатури функцій | Працює, але не прийнято | Так |
| Mapped types | Ні | Так |
| Conditional types | Ні | Так |
implements у класах | Так (стандартно) | Так (працює) |
| Найкраще для | Контракти об'єктів, класи, аугментація бібліотек | Union-типи, примітиви, трансформації типів |
Як компілятор обробляє це
TypeScript трактує декларації interface як відкриті контейнери. Якщо компілятор бачить дві декларації з однаковою назвою, він об'єднує їх властивості в один тип. Псевдоніми type розгортаються як пряма підстановка: компілятор замінює кожне посилання на псевдонім його визначенням. Жоден з них не виживає після компіляції. Обидва повністю видаляються, коли TypeScript транспілює код у JavaScript.
Типові помилки
Спроба створити union через interface
// НЕПРАВИЛЬНО - синтаксична помилка
// interface Result = User | Product;
// ПРАВИЛЬНО
type Result = User | Product;interface не може виразити "або A, або B". Це концепція type.
Очікування злиття від type
// НЕПРАВИЛЬНО
type Config = { apiUrl: string };
type Config = { timeout: number }; // ПОМИЛКА: Duplicate identifier
// ПРАВИЛЬНО - interface, якщо потрібне злиття
interface Config {
apiUrl: string;
}
interface Config {
timeout: number;
} // об'єднується автоматично
const config: Config = { apiUrl: "...", timeout: 5000 }; // працюєОпціональні властивості замість discriminated union
// НЕПРАВИЛЬНО - дозволяє неможливі комбінації стану
interface State {
status: "idle" | "loading" | "success" | "error";
data?: User;
error?: string; // ніщо не забороняє мати і data, і error одночасно
}
// ПРАВИЛЬНО - discriminated union робить невалідні стани неможливими
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };Цей патерн вимагає type. Аналога через interface немає.
& замість extends для наслідування об'єктів
// Працює, але приховує ієрархію
type AdvancedConfig = BaseConfig & { ssl: boolean };
// Зрозуміліше, якщо ієрархія має значення
interface AdvancedConfig extends BaseConfig {
ssl: boolean;
}Тут є реальна різниця в поведінці. extends одразу видає помилку при конфлікті властивостей. З & конфліктний тип стає never - тихий глухий кут без помилки, який проявиться тільки коли спробуєш використати цю властивість.
interface для сигнатур функцій
// Працює, але виглядає незвично
interface Callback {
(error: Error | null, data: string): void;
}
// Чистіше і поширеніше
type Callback = (error: Error | null, data: string) => void;Реальне застосування
- React: props через
interfaceрозширюютьReact.PropsWithChildren; стан компонента використовує discriminatedtypeunion - Redux: типи екшенів - discriminated
typeunion для type-safe reducers; форма стору -interface - Express: middleware додає властивості до
Requestчерез злиття деклараційinterface; обробники маршрутів використовуютьtypeдля union-відповідей - NestJS: DTO через
interfaceдля наслідування; API-відповіді черезtypeдля discriminated union - Автори бібліотек: всі публічні контракти як
interface, щоб споживачі могли їх аугментувати без зміни вихідного коду
Питання для поглиблення
Q: Чи можна використовувати type в implements у класі?
A: Так, структурно це працює. Але interface - стандартний вибір, бо сигналізує про намір і підтримує злиття декларацій. Використання type з implements не помилка, просто не прийнято.
Q: Що відбувається, коли при extends є конфлікт властивостей?
A: TypeScript одразу видає помилку в місці extends. З & в type конфліктний тип стає never замість помилки. Різниця важлива: never - тихий глухий кут, extends - чітка помилка прямо в місці конфлікту.
Q: Як злиття декларацій (declaration merging) працює з generics?
A: Параметри generics мають збігатися точно між злитими деклараціями. Два interface Box<T> зливаються нормально. Але interface Box<T, U> і interface Box<T> - це різні інтерфейси, вони не зіллються.
Q: Чому Express, NestJS і Fastify використовують interface для публічних типів?
A: Бо споживачі мають їх розширювати. Express middleware додає властивості до Request, перевизначаючи interface в локальному коді. Якби Express використав type Request = { ... }, така аугментація не компілювалась би.
Q: (Senior) Як спроектувати публічну систему типів, щоб споживачі могли розширювати їх без зміни твого коду?
A: Експортуй всі публічні контракти як interface, а не type. Це дає споживачам змогу використовувати злиття декларацій у своїх файлах - вони додають властивості, перевизначаючи твій інтерфейс. З type вони обмежені тим, що ти їм дав. Саме так працює аугментація Request в Express.
Приклади
Розширення об'єкта через interface
interface BaseProps {
className?: string;
children: React.ReactNode;
}
interface ButtonProps extends BaseProps {
onClick: () => void;
variant: "primary" | "secondary";
}
const Button: React.FC<ButtonProps> = ({
onClick,
variant,
children,
className,
}) => (
<button onClick={onClick} className={`btn btn-${variant} ${className}`}>
{children}
</button>
);ButtonProps автоматично отримує className і children з BaseProps. Ключове слово extends одразу показує ієрархію і відловлює конфлікти під час компіляції.
Discriminated union через type (стейт-машина API)
type ApiResponse =
| { status: "success"; data: User[] }
| { status: "error"; error: string }
| { status: "loading" };
function handleResponse(response: ApiResponse) {
switch (response.status) {
case "success":
console.log(response.data); // TypeScript знає, що data існує тут
break;
case "error":
console.error(response.error); // TypeScript знає, що error існує тут
break;
case "loading":
console.log("Чекаємо...");
break;
}
}TypeScript використовує поле status для звуження типу в кожній гілці. Додай новий статус і забудь його обробити - TypeScript помітить пропуск.
Аугментація бібліотеки через злиття декларацій
declare global {
namespace Express {
interface Request {
user?: {
id: string;
role: "admin" | "viewer";
};
}
}
}
app.get("/profile", (req, res) => {
if (req.user?.role === "admin") {
// TypeScript знає, що user.role має рівно два можливих значення
}
});Це працює тому, що Express експортує Request як interface. Блок declare global зливає наші доповнення з існуючим визначенням. З type цей код не компілювався б.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.