TypeScript. Интерфейсы

9 min read

В предыдущей статье по TypeScript мы познакомились с основными типами данных языка, а так же с возможностью создавать собственные примитивные типы.

Как гласит документация к языку, одним из основных принципов TypeScript является то, что типизация основана на структуре (shape) объектов. Такой способ типизации называют неявной или «утиной» — объект относят к тому или иному типу (классу, интерфейсу), если он имеет (реализует) все его свойства и методы. Интерфейсы в TS применяются как раз для того, чтобы описывать нужные вам типы.

Например, у нас есть функция callPet:

function callPet (pet) { console.log(`Hey, ${pet.name}, come here!`); }

Она принимает в качестве аргумента объект питомца. Опишем его с помощью интерфейса:

interface Pet { name: string; age: number; color: string; }

Питомец имеет имя, возраст и окрас.

Теперь укажем, что callPet принимает именно объект питомца. И вызовем функцию:

function callPet (pet: Pet): void { console.log(`Hey, ${pet.name}, come here!`); } callPet({name: 'Bobby', age: 2, color: 'white'})

Заметим, что объект питомца создан “на месте”. Нигде не указано, что он реализует интерфейс Pet, проверка происходит только на соответствие свойств в самой функции callPet.

Если в передаваемый объект добавить свойство, которого нет в интерфейсе или, наоборот, убрать одно из описанных, компилятор TS выдаст ошибку о несоответствии типов. Это и есть неявная или «утиная» типизация.

Этот пример показывает основное назначение и использование интерфейсов в TS, далее мы рассмотрим другие возможности по работе с собственными типами в TypeScript.

Реализация интерфейсов

Как и во многих языках программирования, поддерживающих парадигму ООП, классы в TS могут “реализовывать” (implements) интерфейсы. Продолжим работать с интерфейсом Pet и опишем его поля более подробно:

interface Pet { name: string; readonly age: number; readonly color: string; readonly bday: Date; toy?: string; }

Возраст и окрас питомца не зависит от внешних факторов, поэтому сделаем эти свойства readonly. Если попытаться записать в такие свойства значения, компилятор выдаст ошибку. Так же добавим свойство bday, чтобы потом иметь возможность вычислить возраст. Свойство toy обозначено как необязательное с помощью ?. Поэтому если не указать его в классе, реализующем интерфейс, код скомпилируется без ошибок. 

Теперь реализуем интерфейс Pet классом Dog:

class Dog implements Pet { name: string; readonly color: string; readonly bday: Date; constructor (name: string, color: string, bday: Date) { this.name = name; this.color = color; this.bday = bday; } get age() { const diff = new Date(new Date().getTime() - this.bday.getTime()); return diff.getFullYear() - new Date(0).getFullYear(); } } const bobby = new Dog('Bobby', 'black', new Date(2010, 4, 12));

Если не реализовать в классе какое-либо свойство интерфейса, компилятор выдаст ошибку.

Note: Наличие модификатора readonly не проверяется, поэтому его нужно не забывать указывать самостоятельно. Для методов-геттеров он учитывается по умолчанию.

Методы

Помимо свойств, интерфейсы могут описывать методы классов:

interface Pet { speak(): void; run(meters: number): void; bringToy?(toy?: string): string; } class Dog implements Pet { speak(): void { console.log('Bark!'); } run(meters: number): void { console.log(`${this.name} ran for ${meters} meters`); } }

Наследование интерфейсов

Интерфейсы в TS могут наследовать друг друга. Например, мы можем сделать Dog не классом, а интерфейсом с полями, которые будут дополнять интерфейс Pet:

interface Dog implements Pet { readonly breed: string; }

Стоит заметить, что одновременно могут существовать и класс Dog, и интерфейс Dog. TypeScript сам разрешит конфликт имен при компиляции, поскольку интерфейсы в скомпилированный .js файл не входят.

Реализацию интерфейса Dog теперь будет выполнять классы для каждой породы. Чтобы не писать все методы заново, можно просто наследовать класс от класса Dog и реализовать им интерфейс Dog:

class GermanShepherd extends Dog implements Dog { readonly breed: string = 'German Shepherd'; } class Dalmatian extends Dog implements Dog { /* При указании значения свойства сразу при объявении, можно не указывать тип */ readonly breed = 'Dalmatian'; } const rex = new GermmanShepherd('Rex', 'black', new Date(2014, 2, 10)); const pongo = new Dalmatian('Pongo', 'dotted', new Date(2012, 7, 3));

Интерфейсы могут наследовать и классы. При наследовании от класса, интерфейс наследует все его свойства и методы (в том числе private и protected), но не наследует их значение и реализацию. Поскольку наследуются приватные свойства, такой интерфейс может реализовать только подкласс исходного класса. 

Типизация массивов

Интерфейсы в TS позволяют задать статическую типизацию для индексируемых типов. Рассмотрим это на простом примере:

interface Shelter { [index: number]: Pet; }

Выше написано примерно следующее: тип Shelter это объект с числовыми индексами, по которым находятся объекты типа Pet. То есть Shelter, по сути, массив питомцев. Можно реализовывать индексацию самостоятельно, но гораздо проще наследовать класс от встроенного класса Array. Для того, чтобы компилятор понял, значения какого типа будут храниться в массиве, укажем его при наследовании: Array. Пример реализации интерфейса Shelter:

class DogShelter extends Array<Dog> implements Shelter { constructor(...dogs: Dog[]) { super(...dogs); } } const shelter = new DogShelter(bobby, rex, pongo); shelter.forEach(dog => dog.speak());

Множественная реализация

Класс может реализовывать больше одного интерфейса. Это бывает удобно, когда нужно логически разделить свойства сложного объекта. Например DogShelter помимо Shelter может реализовывать интерфейс Building:

interface Building { address: string; type: string; } class DogShelter extends Array<Dog> implements Shelter, Building { address: string; type: string; constructor(addess: string, ...dogs: Dog[]) { super(...dogs); this.address = address; this.type = 'shelter' } }

Типизация функций

Интерфейсы могут быть использованы для того, чтобы описать функцию:

interface Build { (address: string, type: string): Building; }

Теперь можно строго определить тип функции с помощью созданного интерфейса:

let buildHouse: Build; buildHouse = function (address: string, type: string) { return {address, type} }; const WinterPalace = buildHouse('Palace Square', 'palace');

Гибридные типы

Интерфейс может совместить в себе описание и функции, и объекта, и индексируемого типа:

interface Build { /* Описание функции */ (name: string, address: string, type: string): Building; /* Описание полей объекта */ developer: string; started: Date; /* Индексируемый тип */ buildings: { [buildingName: string]: Building } } function DevelopingProject(developer: string, started: Date) { let project = <Build> function ( name: string, address: string, type: string ): Building { const building: Building = {address, type}; project.buildings[name] = building; return building; } project.buildings = {}; project.developer = developer; project.started = started; return project; } const SPb = DevelopingProject('Peter the Great', new Date(1703)); const PeterAndPaulsFortress = SPb('Peter and Paul`s Fortress', 'Rabbit Island', 'fortress');

Главное

Интерфейсы в TS полезный и очень удобный инструмент для создания собственных типов данных. Они отлично подходят для описания классов и объектов и помогают избежать ошибок, связанных с неявными присваиваниями и преобразованиями.

 С помощью интерфейсов, можно строго задавать структуру приложения. Например, популярный фреймворк Angular версии 2.0 использует интерфейсы, чтобы описывать жизненный цикл компонентов, а умные IDE умеют распознавать и подсказывать ошибки еще на уровне разработки, до компиляции.

Но важно помнить, что интерфейсы TS сильно отличаются от привычных интерфейсов ООП — они выполняют только функции пользовательских типов и не более. Именно поэтому они могут наследовать классы и другие интерфейсы.