Table of Contents #
- Введение
- Наш первый интерфейс
- Опциональные свойства
- Свойства только для чтения
- Проверки на лишние свойства
- Функциональные типы
- Индексируемые типы
- Типы классов
- Расширение интерфейсов
- Гибридные типы
- Интерфейсы
Введение #
Одним из основных принципов TypeScript является то, что проверка типов основывается на форме значений. Этот подход иногда называется "утиной типизацией" либо "структурным подтипированием". В TypeScript интерфейсы выполняют функцию именования типов, и являются мощным способом определения соглашений внутри кода, а также за пределами проекта.
Наш первый интерфейс #
Самый простой способ увидеть, как работают интерфейсы — начать с простого примера:
function printLabel(labelledObj: { label: string }) { console.log(labelledObj.label); } let myObj = {size: 10, label: "Size 10 Object"}; printLabel(myObj);
Компилятор проверяет вызов printLabel
. Эта функция принимает один параметр, который требует, чтобы у переданного объекта было свойство под именем label
, которое имело бы строковый тип. Обратите внимание, что у нашего объекта есть и другие свойства, однако компилятор проверяет лишь то, что у него есть, по крайней мере, необходимые свойства, и их типы совпадают с требуемыми. В некоторых случаях, которые мы рассмотрим чуть позже, TypeScript ведет себя не так снисходительно.
Мы можем переписать этот пример, на этот раз используя интерфейс для того, чтобы отразить необходимость наличия свойства label
строкового типа:
interface LabelledValue { label: string; } function printLabel(labelledObj: LabelledValue) { console.log(labelledObj.label); } let myObj = {size: 10, label: "Size 10 Object"}; printLabel(myObj);
Интерфейс LabelledValue
— это имя, которое теперь можно использовать, чтобы задать требование из предыдущего примера. Он по-прежнему отражает необходимость того, что объект должен иметь свойство строкового типа с именем label
. Обратите внимание — совсем не обязательно явно указывать, что объект, который мы передаем в printLabel
, реализует данный интерфейс, как пришлось бы делать в иных языках. В TypeScript имеет значение только форма объекта. Если объект, который передается в функцию, удовлетворяет перечисленным требованиям, то он считается подходящим.
Стоит отметить, что проверка типов не требует, чтобы свойства шли в определенном порядке: важно лишь, что необходимые свойства присутствуют и имеют подходящий тип.
Опциональные свойства #
Не все свойства интерфейса могут быть обязательными. Некоторые существуют только в определенных условиях, либо отсутствуют вообще. Такие необязательные (опциональные) свойства часто встречаются, к примеру, при передаче в функцию аргументов в виде объекта, в котором указаны всего несколько свойств.
Вот пример реализации такого приема:
interface SquareConfig { color?: string; width?: number; } function createSquare(config: SquareConfig): {color: string; area: number} { let newSquare = {color: "white", area: 100}; if (config.color) { newSquare.color = config.color; } if (config.width) { newSquare.area = config.width * config.width; } return newSquare; } let mySquare = createSquare({color: "black"});
Интерфейсы с необязательными свойствами записываются подобно обычным, но каждое опциональное свойство помечается символом ?
в конце имени.
Преимущество опциональных свойств заключается в том, что можно описать свойства, которые, возможно, будут присутствовать, и в то же время запретить использование тех свойств, которые не являются частью интерфейса. К примеру, если бы мы ошиблись при вводе имени color
в createSquare
, то получили бы сообщение об ошибке, информирующее об этом:
interface SquareConfig { color?: string; width?: number; } function createSquare(config: SquareConfig): { color: string; area: number } { let newSquare = {color: "white", area: 100}; if (config.color) { // Ошибка: Property 'collor' does not exist on type 'SquareConfig' newSquare.color = config.collor; } if (config.width) { newSquare.area = config.width * config.width; } return newSquare; } let mySquare = createSquare({color: "black"});
Свойства только для чтения #
Некоторые свойства должны быть изменяемыми только в момент создания объекта. Вы можете указать это, добавив readonly
перед его именем:
interface Point { readonly x: number; readonly y: number; }
Создать объект Point
можно присваиванием объектного литерала, но после присваивания изменить x
и y
будет больше нельзя.
let p1: Point = { x: 10, y: 20 }; p1.x = 5; // ошибка!
В TypeScript есть тип ReadonlyArray<T>
, который, по сути, является типом Array<T>
, из которого удалены все изменяющие его методы, так что можно быть уверенным, что такие массивы не будут изменяться после создания:
let a: number[] = [1, 2, 3, 4]; let ro: ReadonlyArray = a; ro[0] = 12; // ошибка! ro.push(5); // ошибка! ro.length = 100; // ошибка! a = ro; // ошибка!
В последней строке примера можно видеть, что даже присваивание ReadonlyArray
обычному массиву недопустимо. Впрочем, это ограничение все равно можно обойти, использовав приведение типов:
a = ro as number[];
readonly
против const
Самый простой способ запомнить, когда нужно использовать readonly
, а когда const
— задать вопрос, нужна ли эта возможность для переменной либо для свойства объекта. С переменными используется const
, а со свойствами — readonly
.
Проверки на лишние свойства #
В нашем первом примере использования интерфейсов TypeScript позволил передать { size: number; label: string; }
там, где ожидалось всего лишь { label: string; }
. Также мы узнали о необязательных свойствах, и о том, как они могут быть полезны при передаче аргументов в функции.
Однако, бездумное сочетание двух этих возможностей позволило бы выстрелить себе в ногу так же, как и в JavaScript. К примеру, если взять последний пример с createSquare
:
interface SquareConfig { color?: string; width?: number; } function createSquare(config: SquareConfig): { color: string; area: number } { // ... } let mySquare = createSquare({ colour: "red", width: 100 });
Обратите внимание, что аргумент, передаваемый в createSquare
, записан как colour
вместо color
. В чистом JavaScript подобные вещи не выдают ошибок, но и не работают так, как хотел бы разработчик.
Можно сказать, что данная программа корректна с точки зрения типов, так как типы свойств width
совместимы, color
отсутствует, а наличие дополнительного свойства colour
не имеет никакого значения.
Однако TypeScript делает предположение, что в этом куске кода есть ошибка. Литералы объектов обрабатываются им по-особенному, и проходят проверку на наличие лишних свойств. Эта проверка делается, когда литералы либо присваиваются другим переменным, либо передаются в качестве аргументов. Если в литерале есть какие-либо свойства, которых нет в целевом типе, то это будет считаться ошибкой.
// ошибка: 'colour' not expected in type 'SquareConfig' let mySquare = createSquare({ colour: "red", width: 100 });
Обойти такую проверку очень легко. Самый простой способ — использовать приведение типов:
let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);
Если же вы уверены, что объект может иметь дополнительные свойства, которые будут использоваться каким-то особенным способом, то есть способ еще лучше — добавить строковый индекс. Если объекты SquareConfig
могут иметь свойства color
и width
, а также любое количество иных свойств, то интерфейс можно описать следующим образом:
interface SquareConfig { color?: string; width?: number; [propName: string]: any; }
Индексы мы обсудим чуть позже, а сейчас просто отметим, что в этом примере SquareConfig
может иметь любое количество свойств, и, если это не color
и не width
, то их тип не имеет значения.
Последний способ обойти проверку на наличие избыточных свойств, который может показаться немножко неожиданным — присваивание объекта другой переменной. Так как squareOptions
не будет проходить проверку на избыточные свойства, компилятор не выдаст ошибки.
let squareOptions = { colour: "red", width: 100 }; let mySquare = createSquare(squareOptions);
Не забывайте, что в простом коде, подобном приведенному выше, скорее всего, не нужно пытаться обойти эту проверку. Для более сложных объектных литералов, в которых есть методы, или которые имеют состояние, возможно, придется прибегнуть к помощи этой техники, однако большинство сообщений компилятора, связанных с проверкой на избыточные свойства, указывают на настоящие ошибки. Это значит, что когда вы сталкиваетесь с проблемами, которые порождает такая проверка (к примеру, при передаче в функцию объекта с аргументами), возможно, придется изменить объявления типов. В данном случае, если передача объекта, в котором могут быть одновременно свойства color
и colour
, приемлема, нужно исправить определение SquareConfig
, чтобы отразить это.
Функциональные типы #
Интерфейсы могут описывать широкий диапазон "форм", которые принимают JavaScript-объекты. Кроме описания объектов со свойствами, интерфейсы могут описывать и типы функций.
Для того, чтобы описать функцию с помощью интерфейса, к нему добавляют сигнатуру вызова. Такая сигнатура выглядит как описание функции, в котором указаны только список аргументов и возвращаемый тип. Каждый параметр в списке должен иметь и имя, и тип.
interface SearchFunc { (source: string, subString: string): boolean; }
Будучи определенным, такой интерфейс может использоваться так же, как и другие интерфейсы. Сейчас мы покажем, как можно создать переменную функционального типа, и присвоить ей функцию.
let mySearch: SearchFunc; mySearch = function(source: string, subString: string) { let result = source.search(subString); if (result == -1) { return false; } else { return true; } }
Имена параметров не обязательно должны совпадать, чтобы функция прошла проверку на соответствие типов. Мы, к примеру, могли бы записать предыдущий пример вот так:
let mySearch: SearchFunc; mySearch = function(src: string, sub: string): boolean { let result = src.search(sub); if (result == -1) { return false; } else { return true; } }
Параметры функций проверяются друг за другом, и типы параметров, находящихся на соответствующих позициях, сравниваются попарно. Если вы не хотите указывать типы для аргументов, то TypeScript сможет вывести типы из контекста, основываясь на том, что функция присваивается переменной, тип которой — SearchFunc
. В следующем примере тип возвращаемого значения функции тоже выводится: это делается на основании значений, которые она возвращает (false
и true
). Если бы функция возвращала числа или строки, то компилятор во время проверки типов предупредил бы, что тип возвращаемого значения не совпадает с типом, указанным в интерфейсе SearchFunc
.
let mySearch: SearchFunc; mySearch = function(src, sub) { let result = src.search(sub); if (result == -1) { return false; } else { return true; } }
Индексируемые типы #
Аналогично тому, как интерфейсы используются для описания функций, можно описать типы так, чтобы с ними можно было использовать оператор индекса — например, вот так a[10]
или так ageMap["daniel"]
. Индексируемые типы имеют сигнатуру индекса, которая описывает типы, которые можно использовать для индексации объекта, а также типы значений, которые возвращает эта операция. Приведем пример:
interface StringArray { [index: number]: string; } let myArray: StringArray; myArray = ["Bob", "Fred"]; let myStr: string = myArray[0];
Здесь у нас есть интерфейс StringArray
, у которого есть сигнатура индекса. Эта сигнатура говорит о том, что когда StringArray
индексируется числом, возвращается строка.
Существуют всего два вида поддерживаемых сигнатур индекса: со строками и с числами в качестве аргумента. Объект может поддерживать оба вида, но тип значения, который возвращается числовым индексом, должен быть подтипом того, который возвращается строковым индексом. Так сделано по той причине, что когда операция индекса применяется к объекту, JavaScript сначала преобразует переданное в качестве индекса число в строку. То есть использование индекса 100
(число) — то же самое, что использование "100"
(строка), поэтому типы обоих индексов должны согласовываться.
class Animal { name: string; } class Dog extends Animal { breed: string; } // Ошибка: индексация строкой может вернуть объект Dog! interface NotOkay { [x: number]: Animal; [x: string]: Dog; }
Кроме того, что строковые индексы — мощный способ описания словарей, они диктуют требование того, чтобы типы всех свойств соответствовали типу, который возвращает операция индекса. Это происходит из-за того, что obj.property
доступен и как obj[property]
. В следующем примере тип name
не совпадает с типом строкового индекса, и компилятор выдает ошибку:
interface NumberDictionary { [index: string]: number; length: number; // все хорошо, length — число name: string; // ошибка, the type of 'name' is not a subtype of the indexer }
Кроме того, сигнатуру индекса можно сделать доступной только для чтения, чтобы запретить присваивание индексам:
interface ReadonlyStringArray { readonly [index: number]: string; } let myArray: ReadonlyStringArray = ["Alice", "Bob"]; myArray[2] = "Mallory"; // ошибка!
Установить myArray[2]
нельзя, поскольку сигнатура индекса — только для чтения.
Типы классов #
Реализация интерфейса
В таких языках, как C# и Java интерфейсы наиболее часто используются для того, чтобы явно указать, что класс соответствует определенному соглашению. Это возможно и в TypeScript.
interface ClockInterface { currentTime: Date; } class Clock implements ClockInterface { currentTime: Date; constructor(h: number, m: number) { } }
Также в интерфейсе можно описать методы, которые реализованы внутри класса, как это сделано для setTime
в следующем примере:
interface ClockInterface { currentTime: Date; setTime(d: Date); } class Clock implements ClockInterface { currentTime: Date; setTime(d: Date) { this.currentTime = d; } constructor(h: number, m: number) { } }
Интерфейсы описывают публичную часть класса, но не приватную. Это не дает возможности указывать с помощью интефейса, что класс должен использовать конкретные типы для своих приватных членов.
Разница между статической частью и экземпляром класса
Работая с классами и интерфейсами, полезно помнить, что класс имеет два типа: тип статической части и тип экземпляра. Вы могли столкнуться с ошибкой, если создавали интерфейс с конструктором, а потом пытались написать класс, который реализовывал бы его:
interface ClockConstructor { new (hour: number, minute: number); } class Clock implements ClockConstructor { currentTime: Date; constructor(h: number, m: number) { } }
Так происходит из-за того, что, когда класс реализует интерфейс, происходит проверка типа только его экземпляра. Конструктор же находится в статической части, и не включается в эту проверку.
Вместо такого подхода нужно работать напрямую со статической частью класса. В следующем примере мы определяем два интерфейса: ClockConstructor
для конструктора, и ClockInterface
для экземпляра класса. Затем, для удобства, мы определяем функцию-конструктор createClock
, которая создает объекты того типа, который передается ей в качестве аргумента.
interface ClockConstructor { new (hour: number, minute: number): ClockInterface; } interface ClockInterface { tick(); } function createClock(ctor: ClockConstructor, hour: number, minute: number): ClockInterface { return new ctor(hour, minute); } class DigitalClock implements ClockInterface { constructor(h: number, m: number) { } tick() { console.log("beep beep"); } } class AnalogClock implements ClockInterface { constructor(h: number, m: number) { } tick() { console.log("tick tock"); } } let digital = createClock(DigitalClock, 12, 17); let analog = createClock(AnalogClock, 7, 32);
Так как первый параметр createClock
имеет тип ClockConstructor
, то в createClock(AnalogClock, 7, 32)
происходит проверка на то, что AnalogClock
имеет подходяющую сигнатуру конструктора.
Расширение интерфейсов #
Интерфейсы могут расширять друг друга, подобно классам. Это позволяет копировать члены одного интерфейса в другой, что дает больше гибкости при разделении интерфейсов на переиспользуемые компоненты.
interface Shape { color: string; } interface Square extends Shape { sideLength: number; } let square = {}; square.color = "blue"; square.sideLength = 10;
Интерфейс может расширять сразу несколько других интерфейсов, создавая их комбинацию:
interface Shape { color: string; } interface PenStroke { penWidth: number; } interface Square extends Shape, PenStroke { sideLength: number; } let square = {}; square.color = "blue"; square.sideLength = 10; square.penWidth = 5.0;
Гибридные типы #
Как говорилось ранее, интерфейсы способны описывать сложные типы, которые встречаются в "боевом" JavaScript. JavaScript — динамичный и гибкий язык, поэтому здесь можно внезапно столкнуться с объектами, которые работают как сочетание нескольких уже описанных типов.
Один из таких примеров — объект, который ведет себя и как функция, и как объект со свойствами:
interface Counter { (start: number): string; interval: number; reset(): void; } function getCounter(): Counter { let counter = function (start: number) { }; counter.interval = 123; counter.reset = function () { }; return counter; } let c = getCounter(); c(10); c.reset(); c.interval = 5.0;
При взаимодействии со сторонним кодом может понадобиться использовать подобные приемы, чтобы полноценно описать тип.
Интерфейсы, расширяющие классы #
Когда интерфейс расширяет класс, интерфейс наследует члены класса, но не их реализацию. Это аналогично тому, как если бы интерфейс описывал все члены класса, но не указывал их реализацию. Интерфейсы наследуют даже приватные и защищенные члены базового класса. Это означает, что если создать интерфейс, расширяющий класс с приватными или защищенными членами, то он может быть реализован только самим базовым классом либо его наследниками.
Это полезно в тех случаях, когда существует большая иерархия наследования, и нужно указать, что код работает только с определенными подклассами, у которых есть определенные свойства. Такие подклассы не обязаны иметь отношение друг к другу, кроме того, что они наследуются от одного и того же базового класса. К примеру:
class Control { private state: any; } interface SelectableControl extends Control { select(): void; } class Button extends Control { select() { } } class TextBox extends Control { select() { } } class Image extends Control { } class Location { select() { } }
В этом примере SelectableControl
содержит все члены класса Control
, включая приватное свойство state
. Так как state
— приватный член, реализовать интерфейс SelectableControl
смогут только наследники Control
. Так будет потому, что для совместимости приватных членов необходимо, чтобы они были объявлены в одном и том же базовом классе, а это возможно лишь для наследников Control
.
Внутри кода Control
можно получить доступ к приватному члену state
через экземпляр SelectableControl
. По сути, SelectableControl
ведет себя так же, как Control
, о котором известно, что у него есть метод select
. Классы Button
и TextBox
— подтипы SelectableControl
(так как оба унаследованы от Control
и у них есть метод select
), однако Image
и Location
таковыми не являются.
Поддержите перевод документации:
Documentation generated by mdoc.