Skip to main content
Практика завдань

type vs interface у TypeScript

type vs interface у TypeScript зводиться до одного питання: ти описуєш об'єктну форму, чи називаєш будь-яку форму, яку компілятор здатен зібрати, включно з union, примітивами і кортежами? Інтерфейси покривають перший випадок і додають два бонуси (declaration merging і чистий extends). type alias покриває обидва випадки, але відмовляється від merging взамін.

Теорія

Чому вибір не просто стилістичний

Обидва описують типи. Обидва проходять ту саму структурну перевірку. Можна передати User, оголошений через interface, у функцію, що чекає type User, поки форма збігається. Справжнє питання не "що гнучкіше", а "які побічні ефекти мені потрібні сьогодні".

interface вміє те, чого не вміє type: компілятор дозволяє повторно оголошувати ту саму назву і мовчки об'єднує поля, а extends дає ланцюжок успадкування, який інструменти та повідомлення про помилки показують чисто. type вміє те, чого не вміє interface: давати ім'я примітиву, кортежу, union, intersection, mapped type, conditional type або чомусь, побудованому через typeof чи keyof.

Як компілятор трактує кожне

interface це іменований об'єктний тип, який тайпчекер тримає як окрему точку. Два оголошення того самого інтерфейсу зливаються в одну точку з об'єднанням їхніх членів. Пошук члена резолвиться саме в цю точку.

type alias це ім'я для типового виразу. Компілятор підставляє праву частину виразу замість імені, коли його зустрічає. Два type з однаковою назвою це помилка, бо один псевдонім не може вказувати на два різних вирази одночасно.

Найпростіше порівняння поряд

typescript
interface User { id: string; email: string; } type UserAlias = { id: string; email: string; }; const a: User = { id: "1", email: "a@b.c" }; const b: UserAlias = a; // працює, структурний збіг

Для простої об'єктної форми обидва варіанти взаємозамінні. Компілятор приймає значення interface там, де чекають type, і навпаки, бо TypeScript використовує структурну типізацію, а не номінальну.

Declaration merging доступний тільки для interface

TypeScript дозволяє повторно оголосити той самий interface в одній області і тихо зливає поля. Спроба зробити те саме з type дає помилку "дублююче ім'я".

typescript
interface Request { id: string; } interface Request { userId: string; // додано до Request } const r: Request = { id: "1", userId: "42" }; // обидва поля обов'язкові

Саме через це автори бібліотек шиплять .d.ts файли з interface оголошеннями для глобальних штук типу Window чи Express Request. Споживач може додати поля зі свого коду, не чіпаючи бібліотеку. На проекті з TypeScript 5.2 ми ввели правило оголошувати всі DTO через interface саме тому, що один з контракторів постійно патчив Express Request через поля для middleware. Через три місяці роботи, нуль колізій оголошень.

Union, intersection і примітиви це територія type

typescript
type Id = string | number; type Point = [number, number]; type Readonly<T> = { readonly [K in keyof T]: T[K] }; type Result<T> = | { ok: true; value: T } | { ok: false; error: Error };

Жоден з цих виразів не скомпілюється, якщо замінити type на interface. Інтерфейс зобов'язаний бути об'єктною формою. Discriminated union це єдиний найпоширеніший патерн, де type не опціональний, і саме тому редьюсери, API клієнти й бібліотеки форм тримають свій публічний API на type.

Та сама перевірка, різна ергономіка

Механічну різницю можна сформулювати одним рядком: обидва виражають об'єктні типи, але interface це точка, на яку компілятор тримає посилання, а type це вираз, який інлайниться в місці використання. Саме тому merging працює для одного і не працює для іншого, тому extends дає приємніші помилки для одного і не для іншого, і саме тому лише type може назвати union.

Практичні правила вибору

interface для об'єктних форм і контрактів класів: DTO, props компонентів, сервісні API, усе, що клас може implements. type для union, кортежів, примітивних псевдонімів, intersection, mapped type, conditional type або будь-чого, виведеного через keyof чи typeof. Не перемикай стилі посеред проекту заради уявної консистентності, перемикай за реальним типом значення.

Якщо потрібен declaration merging (аугментація глобалів, ambient типи бібліотек), interface єдиний варіант, який робить цю роботу.

Поширена помилкова думка

Інтерфейси повільніші за type, і взагалі вони ж однакові? Обидва питання стабільно вилазять на middle співбесідах. Коротка відповідь на перше: ні, не так, щоб це мало значення для прикладного коду. Компілятор кешує резолвнуті псевдоніми, і різниця відчувається хіба що на дуже великих монорепозиторіях з глибоко вкладеними generic параметрами. Коротка відповідь на друге: теж ні. Вони перетинаються на простих об'єктних формах, але щойно тобі потрібен union, примітивний псевдонім або mapped type, скомпілюється лише type, а щойно потрібен declaration merging або можливість для класу робити implements публічного контракту, що його можна аугментувати ззовні, підійде лише interface.

Як це стикається з класами і бібліотеками

Клас може реалізувати обидва варіанти. class User implements UserType {} працює так само, як class User implements UserInterface {}, з точки зору самого класу. Практична перевага в тому, що споживачі бібліотеки, які хочуть дописати базовий контракт зі свого коду, можуть це зробити тільки з interface. Саме тому NestJS, Express, Angular і більшість екосистеми TypeScript публікують публічні контракти як інтерфейси навіть у тих місцях, де type скомпілювався б без проблем. Більше контексту в офіційному TypeScript handbook про об'єктні типи.

Приклади

DTO, який ділять сервіс і тестова фікстура

typescript
interface OrderDto { id: string; customerId: string; totalCents: number; createdAt: string; } function buildOrderFixture(overrides: Partial<OrderDto> = {}): OrderDto { return { id: "ord_1", customerId: "cus_42", totalCents: 1999, createdAt: "2025-01-01T00:00:00Z", ...overrides, }; } const order = buildOrderFixture({ totalCents: 4500 }); // order.totalCents === 4500, решта полів залишаються зі значеннями за замовчуванням

OrderDto це звичайний об'єктний контракт, тому interface тут природний вибір. Якщо колись знадобиться додати поле (скажімо, аудиторське через спільний пакет типів), споживач зможе це зробити, не чіпаючи оригінальний файл. Partial<OrderDto> все одно працює, бо для утилітних типів компілятор трактує інтерфейси як звичайні об'єктні типи.

Клієнт API, відповідь якого це discriminated union

typescript
type ApiResult<T> = | { ok: true; data: T } | { ok: false; error: { code: string; message: string } }; async function getOrder(id: string): Promise<ApiResult<OrderDto>> { const res = await fetch(`/orders/${id}`); if (!res.ok) { return { ok: false, error: { code: "http_error", message: res.statusText }, }; } return { ok: true, data: await res.json() }; } const result = await getOrder("ord_1"); if (result.ok) { console.log(result.data.totalCents); // звужено до OrderDto } else { console.error(result.error.code); // звужено до форми помилки }

ApiResult неможливо написати як interface, бо його зовнішня форма це union, а не один об'єкт. Саме поле ok і вмикає звуження типу: після if TypeScript точно знає, в якій з гілок union ти опинився, і показує поля лише для цієї гілки. type доречний щоразу, коли типове значення має більше ніж одну легітимну форму. Решта коду може далі використовувати interface для плоских шматків на кшталт OrderDto, і обидва стилі мирно живуть в одному модулі.

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

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

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

Дочитали статтю?
Практика завдань