Повернутися до блогу
31 жовт. 2024 р.
8 хвилин читання

Type vs Interface. Що обрати?

Ключові відмінності і приклади використання

Якщо ви працюєте з TypeScript, то, ймовірно, вже стикалися з питанням: що краще використовувати для оголошення типів — type чи interface? Сьогодні ми в цьому розберомось

Тут тільки type

По-перше, варто зазначити, що питання вибору між type і interface не актуальне, коли мова йде про типи, де використання type є нашою єдиною опцією.

Types

Ключове слово type використовується для створення псевдонімів типів (Type aliases). Це означає, що ми можемо створити псевдоніми для будь-якого типу, включаючи:

Літеральні типи:

type LuckyNumber = 13;
type True = true;
type HeWhoMustNotBeNamed = 'Voldemort';

Примітивні типи:

type Name = string;
type Choice = boolean;

Кортежі (Tuples)

type MagicalCreatureSighting = [creatureName: string, location: string, isDangerous: boolean, numberSeen: number];

Об’єднання типів (Union types):

type PowerfulWizard = "Albus Dumbledore" | HeWhoMustNotBeNamed;

Mapped Types:

type MyPartial<T> = { [P in keyof T]?: T[P]; };

Функції:

type CastSpell = (spell: "Expelliarmus" | "Lumos" | "Alohomora", target: string) => void;

Обʼєкти

type Wizard = {
  name: string;
  house: 'Gryffindor' | 'Hufflepuff' | 'Ravenclaw' | 'Slytherin';
  age: number;
  knownSpells: string[];
  pet?: 'Owl' | 'Cat' | 'Toad';
}

Тобто за допомогою type ми можемо оголосити будь який типи - на те вони і псевдоніми для типів.

Interfaces

Натомість, Інтерфейси (interfaces) у TypeScript призначені виключно для визначення структури об’єктів і функцій.

Тому питання вибору між type і interface виникає лише тоді, коли ми працюємо з об’єктами або функціями. В усіх інших випадках, коли мова йде про інші типи, наша єдина опція — це type.

Функції

Що ж стосується функцій і об’єктів? У випадку з функціями питання вибору, як правило, не виникає, оскільки більшість розробників надають перевагу використанню псевдонімів типів (type aliases) через їх більш явний і зрозумілий синтаксис.

Для порівняння:

type CastSpell = (spell: "Expelliarmus" | "Lumos" | "Alohomora", target: string) => void;

interface CastSpell { 
   (spell: "Expelliarmus" | "Lumos" | "Alohomora", target: string): void;
}

Як бачите, синтаксис type для функцій виглядає простіше і зрозуміліше.

Об’єкти

А ось для обʼєктів думки можуть різнитися і крім синтаксису є і більш вагомі відмінності, але почнемо ми саме з нього

Синтаксис

  1. Для type - після назви ми ставимо знак рівності, для interface - не ставимо
type Animal = {
     name: string
}

interface Animal {
    name: string
}
  1. Для розширення type ми вокристовуємо знак амперсанду (в Typescript він назвається intersection) - іншими словами, створюємо перетин типів (intersection type). А для розширення interface ми використовуємо звичне для ООП ключове слово extends
type Goat = Animal & {
    isSmart: true
}

interface Sheep extends Animal {
  isSmart: false
  isCute: true
}

Швидкість

Розібравшись синтаксисі, давайте подивимось на більш вагомі відмінності, і почнемо з тих, про які можно прямо прочитати в документації Typescript > Handbook > Everyday Types

Почнемо з швидкості:

Using interfaces with extends can often be more performant for the compiler than type aliases with intersections

Використання інтерфейсів з extends часто може бути продуктивнішим для компілятора, ніж type aliases із перетинами (intersections).

І в посиланні на те як оптимізувати швидкість в Typescript - першою порадою ми бачимо використовувати interface extends замість type intersections.

То ж, що значить на практиці ця різниця в швидкості і коли вона може бути відчутна?

Поперше, треба розуміти, що це не той performance, який впливає на клієнтський досвід користування вашим продуктом - додатком, вебсайтом, тощо.

Це швидкість того, як компілюється ваш TS код. Тож негативний вплив на performance в данному випадку - це збільшення часу, за який буде проходити type check і будуть створюватися артефакти збірки (файли .js і .d.ts ).

Це performance, який може впливати на ваш досвід яки розробника (developer experience/DX) .І найбільшу різницю можна буде відчути наприклад тоді, коли у вас великий проект, який треба регулярно збирати (наприклад, як частину CI процессу), що в свою чергу потребує компіляції всього TS коду.

Наприклад в цьому репозиторії https://github.com/edishu/intersect порівнюється час на компіляцію 10 тисяч інтерфейсів, які рекурсивно використовують extends і 10 тисяч type intersection - як результат варіант з інтерфейсами займає 1 min 26 sec 629 ms, варіант з intersections - 2 min 33 sec 117 ms.

Declaration merging

Наступна цікава відмінність між types і interfaces - це те що назвається Declaration merging - який властивий тільки інтерфейсам і в базовому прикладі працює так - якщо ви оголосите інтерфейси з одним імʼям декілька разів, TypeScript автоматично об’єднає їх в один інтерфейс.

interface Animal {
  species: string;
  sound: string;
}

interface Animal {
  favoriteFood: string;
  canFly: boolean;
}

const cat: Animal = {
  species: 'Cat',
  sound: 'Meow',
  favoriteFood: 'Fish',
  canFly: false // Not yet, but we're working on it!
};  // OK

Ми не будемо тут детально розглядати для чого потрібна така незвична фіча і особливості її використання, зробимо це в окремій статті, натомість зазначимо, що для більш менш повсякденних задач declaration merging це радше те, що може призводити до небажаних помилок, наприклад, якщо ви не помітили існуючий інтерфейс, і випадково продублювали його десь нижче в файлі, що приведе до того, що в цьому інтерфейсі будуть властивості із двох оголошенних інтерфейсів.

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

Помилки

Наступна різниця - це те що інтерфейс з extends краще обробляють помилки ніж type з intersection. Про це теж можна прочитати в документаціі Typescript > Handbook > Objects

Давайте розглянемо наступний приклад

Playground

Припустимо у нас є interface Person

interface Person {
  name: string;
  age: string;
}

Якщо ми спробуємо розширити інтерфейс Person, але при цьому перепишимо тип будь-якої властивості на несумісний тип, то ми отримаємо ось таку помилку

interface IPerson extends Person {
  //      ~~~~~~~ Interface 'IPerson' incorrectly extends interface 'Person'.
  //                Types of property 'age' are incompatible.
  //                  Type 'number' is not assignable to type 'string'.
  age: number;
}

Якщо аналогічну дію проробити з types i intersection - то помилки в нас не буде, натомість властивість age отримає тип never.

type TPerson = Person & { age: number; };  // no error, unusable type

І тут більш бажаною поведінкою буде те, як працює interface, тому що ми швидше зможемо виявити помилку

Index signature

Наступна невелика відмінність - це те що type alias-es мають неявну індексну сигнатуру (implicit index signature), а інтерфейси - ні. Про що йде мова, краще показати на прикладі

Playground

interface Animal {
  name: 'some animal'
}

declare const animal: Animal;

const handleRecord = (obj:Record<string, string>) => { }

const result = handleRecord(animal)
//                          ~~~~~~~ 
//    Argument of type 'Animal' is not assignable to parameter of type 'Record<string, string>'.
//      index signature for type 'string' is missing in type 'Animal'.(2345)

В данноми прикладі в handleRecord(animal) ми отримаємо помилку

Помилка тут зʼявляється через те, що інтерфейси можуть бути розширені за допомогою declaration merging і не факт, що нові властивості Animal будуть входити в рамки Record<string,string>

Щоб це виправити, нам треба або явно додати індексну сигнатуру в Animal, на кшталт

interface Animal {
  name: 'some animal'
  [key: string]: string
}

або ж просто заміити interface на type

type Animal = {
  name: 'some animal'
}

Імʼя в помилках

Ще є супер маленька відмінность в тому, як показуються помилки, але ця відмінність настільки незначна, що не факт, що ви побачити різницю з першого погляду

Playground

Подібність

Це все по відмінностям, в усьому ж іншому, все що стосується обʼектів - interface і type - поводяться однаково, наприклад

  • Вони можуть бути дженеріками,
  • interface може наслідувати type
  • класс може реалізувпти як interface так і type.

Висновки

Cеред великих відмінностей між інтерфейсами і псевдонімами для типів є два вирішальні фактори, на яки б я звернув би увагу:

  1. Інтерфейси переважають в performance.
  2. З іншого боку в них є механізм delcaration merging, що може приводити до несподіваних помилок.

Всі ж інші відмінності на мй власний розсуд не такі суттєві.

Офіційна документація рекомендує використовувати interface до тієї пори, поки вам в конкретному випадку не знадобляться ті функції, які є у type alias, і яких бракує інтерфейсам, наприклад mapped types або union types.

For the most part, you can choose based on personal preference, and TypeScript will tell you if it needs something to be the other kind of declaration. If you would like a heuristic, use interface until you need to use features from type.

Багато статей від різних авторів на просторах інтернету рекомендують усюди використовувати type, щоб бути послідовним і не наражати себе на небезпеку випадкового розширення інтерфейсів за допомогою declaration merging.

Я б порекомендував робити наступним чином

  • Якщо у вас legacy проект - використовуйте те, що прийнято писати на цьому проекті.

  • Якщо це новий проект - використовуйте те, що вам більше до вподоби, тому що навіть ті “суттєві” відмінності не такі вже і суттєві.

    • Якщо у вас нема вподобань, і це буде більш-менш великий проект, для якого буде важливим час компіляції Typescript-a, то віддайте перевагу interface,в іншому ж впадку використовуйте type :)

Дякую за увагу!