Перевод статьи Todd Motto: Classes vs Interfaces in TypeScript.
Классы и интерфейсы являются мощными инструментами, которые облегчают не только объектно-ориентированное программирование, но и проверку типов в TypeScript. Класс — это шаблон (blueprint), используя который мы можем создавать экземпляры объектов, у которых будет точно такая же конфигурация, как и у шаблона — те же свойства и методы. Интерфейс — это группа взаимосвязанных свойств и методов, которые описывают объект, но не обеспечивают реализацию или инициализацию этих свойств и методов в объектах.
Поскольку обе эти структуры определяют, как выглядит объект, обе могут использоваться в TypeScript для создания объектов. Решение использовать класс или интерфейс полностью зависит от конкретного случая: нужна ли только проверка типов, необходима ли реализация деталей (обычно через создание нового экземпляра) или оба случая одновременно?
Мы можем использовать классы для проверки типов и реализации деталей, но мы не можем получить тот же результат при помощи интерфейсов. Понимание того, что мы можем получить при использовании класса или интерфейса, позволит нам принять наилучшее решение, которое улучшит наш код и повысит наш опыт как разработчиков.
В стандарте ES6 официально был представлен класс для экосистемы JavaScript. TypeScript расширяет JavaScript-классы такими возможностями, как проверка типов и статические свойства. Это также означает, что всякий раз, когда происходит преобразование TypeScript-кода в JavaScript-код, транспилятор будет сохранять весь код с классами в преобразованном JavaScript-файле. Следовательно, классы присутствуют на всех этапах создания и преобразования кода.
Мы используем классы как фабрики объектов. Класс определяет схему того, как должен выглядеть и действовать объект, а затем реализует этот объект, инициализируя свойства класса и определяя его методы. Поэтому, когда мы создаем экземпляр класса, мы получаем объект, который имеет действующие функции и определенные свойства.
Давайте рассмотрим пример определения класса PizzaMaker
:
class PizzaMaker {
static create(event: { name: string; toppings: string[] }) {
return { name: event.name, toppings: event.toppings };
}
}
PizzaMaker
— это простой класс. Он имеет статический метод, называемый create
. Что делает этот метод особенным, так это то, что мы можем его использовать, не создавая экземпляр класса. Мы просто вызываем метод непосредственно в классе — так же, как если бы мы работали с чем-то вроде Array.from
:
const pizza = PizzaMaker.create({
name: 'Inferno',
toppings: ['cheese', 'peppers'],
});
console.log(pizza);
// Output: { name: 'Inferno', toppings: [ 'cheese', 'peppers' ] }
Вызов метода PizzaMaker.create()
возвращает новый объект — не класс — со свойствами name
и toppings
, определенными в объекте, который передан этому методу в качестве аргумента.
Если в классе PizzaMaker
метод create
не определен как статический (static) метод, то для использования данного метода нам понадобится создать экземпляр класса PizzaMaker
:
class PizzaMaker {
create(event: { name: string; toppings: string[] }) {
return { name: event.name, toppings: event.toppings };
}
}
const pizzaMaker = new PizzaMaker();
const pizza = pizzaMaker.create({
name: 'Inferno',
toppings: ['cheese', 'peppers'],
});
console.log(pizza);
// Output: { name: 'Inferno', toppings: [ 'cheese', 'peppers' ] }
Мы получаем тот же результат, что и при использовании метода create
как статического (static) метода. Возможность использования классов TypeScript с и без существующего экземпляра класса делает их чрезвычайно универсальными и гибкими. Добавление статических свойств и методов в класс заставляет их действовать как singleton, определяя нестатические свойства и методы, чтобы они действовали как фабрика.
Еще одним уникальным для TypeScript свойством является возможность использовать классы для проверки типов. Давайте объявим класс, который определяет, как должен выглядеть класс Pizza
:
class Pizza {
constructor(
public name: string,
public toppings: string[]
) {}
}
В определении класса Pizza
мы используем удобную возможность сокращения в TypeScript для определения свойств класса из аргументов конструктора — это экономит много времени! При помощи класса Pizza
можно создавать экземпляры объектов, у которых есть свойства name
и toppings
:
const pizza = new Pizza('Inferno', ['cheese', 'peppers']);
console.log(pizza);
// Output: Pizza { name: 'Inferno', toppings: [ 'cheese', 'peppers' ] }
Помимо названия Pizza
перед объектом pizza
, который показывает, что данный объект фактически является экземпляром класса Pizza
, результат работы команд new Pizza(...)
и PizzaMaker.create(...)
одинаков. Оба подхода дают объект с одной и той же структурой. Поэтому мы можем использовать класс Pizza
для проверки типов аргумента event
метода PizzaMaker.create(...)
:
class Pizza {
constructor(
public name: string,
public toppings: string[]
) {}
}
class PizzaMaker {
static create(event: Pizza) {
return {
name: event.name,
toppings: event.toppings
};
}
}
Мы сделали класс PizzaMaker
более декларативным и, следовательно, гораздо более удобочитаемым. Как вариант, если нам нужно обеспечить ту же структуру объектов, что и в классе Pizza
, в других местах, у нас теперь есть портативная конструкция! Добавьте свойство export
в определении класса Pizza
, и вы получите доступ к нему из любой точки приложения.
Использование Pizza
как класса является хорошим вариантом, если мы хотим определить и создать класс Pizza
; но что, если мы хотим только определить структуру класса Pizza
, но нам никогда не понадобится ее создавать? Вот когда пригодится interface
!
В отличие от классов, interface
представляет собой виртуальную структуру, которая существует только в контексте TypeScript. Компилятор TypeScript использует интерфейсы исключительно для целей проверки типов свойств. Как только TypeScript-код будет преобразован в JavaScript-код, последний будет очищен от интерфейсов — в JavaScript нет интерфейсов, поэтому для них там нет места.
И хотя класс может определять factory
или singleton
путем инициализации своих свойств и реализации своих методов, интерфейс — это просто структурный контракт, который определяет свойства объекта — как имя свойства, так и тип свойства. То, как вы реализуете или инициализируете свойства внутри interface
, не имеет к интерфейсу никакого отношения. Давайте посмотрим пример, в котором преобразуем класс Pizza
в интерфейс Pizza
:
interface Pizza {
name: string;
toppings: string[];
}
class PizzaMaker {
static create(event: Pizza) {
return {
name: event.name,
toppings: event.toppings
};
}
}
Поскольку Pizza
как класс или как интерфейс используется классом PizzaMaker
исключительно для проверки типов, рефакторинг Pizza
как интерфейса никак не повлиял на тело класса PizzaMaker
. Заметьте, что интерфейс Pizza
просто перечисляет свойства name
и toppings
и определяет их тип.
Существенным изменением является то, что мы больше не можем создавать экземпляр Pizza
. Давайте дополнительно объясним это основное различие между интерфейсом и классом, рассмотрев Pizza
снова как класс.
Как уже упоминалось ранее, текущий код обеспечивает проверку типов свойств для Pizza
, но не может создать пиццу:
interface Pizza {
name: string;
toppings: string[];
}
class PizzaMaker {
static create(event: Pizza) {
return {
name: event.name,
toppings: event.toppings
};
}
}
Это плохо, потому что у нас отсутствует прекрасная возможность для дальнейшего улучшения декларативного характера и удобочитаемости нашего кода. Обратите внимание, что метод PizzaMaker.create()
возвращает объект, который, безусловно, очень похож на пиццу!
У этого объекта есть свойство name
типа string
и есть свойство toppings
, которое является строковым массивом — мы получаем эти типы свойств из объекта event
, которым является Pizza
. Но разве не было бы здорово, если бы мы могли вернуть экземпляр Pizza
из вызова метода PizzaMaker.create()
?
Как уже упоминалось ранее, мы не можем создать экземпляр интерфейса Pizza
, так как это приведет к ошибке. Тем не менее, мы можем снова преобразовать интерфейс Pizza
в класс и затем вернуть экземпляр Pizza
:
class Pizza {
constructor(
public name: string,
public toppings: string[]
) {};
}
class PizzaMaker {
static create(event: Pizza) {
return new Pizza(
event.name,
event.toppings
);
}
}
const pizza = PizzaMaker.create({
name: 'Inferno',
toppings: ['cheese', 'peppers']
};
Мы применяем структуру, в которой метод PizzaMaker.create()
принимает аргумент event
, сохраняя при этом возможность создания объекта, который определяет тип Pizza
как класс! Здесь мы получаем лучшее из обоих миров — шаблон (blueprint) и контракт. Все зависит от того, какой подход и для какого случая необходим.
Мы многому научились, не погружаясь в большое количество кода. Обобщить в двух словах — если необходимо создать экземпляр объекта, при этом получив преимущества проверки типов таких сущностей как аргументы, возвращаемые типы или generics — имеет смысл использовать класс.
Если вы не создаете экземпляры — в нашем распоряжении есть интерфейсы и их преимущество заключается в том, что при этом не создается какой-либо дополнительного кода, но предоставляется возможность "виртуальной" проверки типизации кода.
Поскольку как интерфейс, так и класс определяют структуру объекта и могут быть взаимозаменяемы в некоторых случаях, стоит отметить, что если нам нужно разделить структурное определение между различными классами, мы можем определить эту структуру в интерфейсе, а затем в каждом классе реализовать этот интерфейс!
Каждый класс должен будет объявить или реализовать каждое свойство интерфейса. В этом заключается мощь и гибкость TypeScript. У нас есть комплексный объектно-ориентированный дизайн в сочетании с универсальной проверкой типов.
Слушайте наш подкаст в iTunes и SoundCloud, читайте нас на Medium, контрибьютьте на GitHub, общайтесь в группе Telegram, следите в Twitter и канале Telegram, рекомендуйте в VK и Facebook.