Skip to main content

Відмінності між 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
  • Обидва повністю видаляються під час компіляції. В рантаймі їх немає

Швидкий приклад

typescript
// 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-проекті.

Таблиця порівняння

Можливістьinterfacetype
Форма об'єктаТакТак
Union-типиНіТак
ПеретинЧерез extendsЧерез &
Злиття деклараційТакНі
Примітиви / кортежіНіТак
Сигнатури функційПрацює, але не прийнятоТак
Mapped typesНіТак
Conditional typesНіТак
implements у класахТак (стандартно)Так (працює)
Найкраще дляКонтракти об'єктів, класи, аугментація бібліотекUnion-типи, примітиви, трансформації типів

Як компілятор обробляє це

TypeScript трактує декларації interface як відкриті контейнери. Якщо компілятор бачить дві декларації з однаковою назвою, він об'єднує їх властивості в один тип. Псевдоніми type розгортаються як пряма підстановка: компілятор замінює кожне посилання на псевдонім його визначенням. Жоден з них не виживає після компіляції. Обидва повністю видаляються, коли TypeScript транспілює код у JavaScript.

Типові помилки

Спроба створити union через interface

typescript
// НЕПРАВИЛЬНО - синтаксична помилка // interface Result = User | Product; // ПРАВИЛЬНО type Result = User | Product;

interface не може виразити "або A, або B". Це концепція type.


Очікування злиття від type

typescript
// НЕПРАВИЛЬНО 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

typescript
// НЕПРАВИЛЬНО - дозволяє неможливі комбінації стану 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 для наслідування об'єктів

typescript
// Працює, але приховує ієрархію type AdvancedConfig = BaseConfig & { ssl: boolean }; // Зрозуміліше, якщо ієрархія має значення interface AdvancedConfig extends BaseConfig { ssl: boolean; }

Тут є реальна різниця в поведінці. extends одразу видає помилку при конфлікті властивостей. З & конфліктний тип стає never - тихий глухий кут без помилки, який проявиться тільки коли спробуєш використати цю властивість.


interface для сигнатур функцій

typescript
// Працює, але виглядає незвично interface Callback { (error: Error | null, data: string): void; } // Чистіше і поширеніше type Callback = (error: Error | null, data: string) => void;

Реальне застосування

  • React: props через interface розширюють React.PropsWithChildren; стан компонента використовує discriminated type union
  • Redux: типи екшенів - discriminated type union для 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

typescript
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)

typescript
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 помітить пропуск.

Аугментація бібліотеки через злиття декларацій

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 цей код не компілювався б.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?