1.Разбираемся со структурной типизацией
Любой типизированный язык программирования призван защищать нас от потенциальных ошибок ещё на этапе разработки. Каждый раз при присваивании значения переменной или передаче аргумента в функцию, интерпретатору приходится сравнивать между собой разные типы. Чем он руководствуется при этом сравнении?
Ответ на этот вопрос зависит от языка программирования. Все типизированные языки программирования делятся на два класса — языки с номинативной типизацией и языки со структурной типизацией. В первой главе мы разберемся чем один подход отличается от другого, а главное поймем как все устроено в TypeScript.
Что такое номинативная типизация?
В языках с номинативной типизацией особое значение имеют названия типов и их иерархия. В таких языках если переменная имеет тип A
, то присвоить ей значение типа B
можно только в двух случаях:
- Если тип
B
является потомком типаA
, - Или если тип
B
это и есть сам типA
Несмотря на то, что типы Planet
и Galaxy
полностью совпадают, следующий код в языках с номинативной типизацией приведет к ошибкам:
typescript// Внутренняя структура типов Planet и Galaxy полностью совпадаетtypePlanet = {name : string; };typeGalaxy = {name : string; };letplanet :Planet = {name : 'Earth' };letgalaxy :Galaxy = {name : 'Milky Way' };// Но несмотря на это, такое присваивание запрещеноType 'Gаlaxy' is not assignable to type 'Plаnet'. Type 'Gаlaxy' is not assignable to type '{ __brand: "planet"; }'. Types of property '__brand' are incompatible. Type '"galaxy"' is not assignable to type '"planet"'.2322Type 'Gаlaxy' is not assignable to type 'Plаnet'. Type 'Gаlaxy' is not assignable to type '{ __brand: "planet"; }'. Types of property '__brand' are incompatible. Type '"galaxy"' is not assignable to type '"planet"'.= plаnet gаlaxy ;Type 'Plаnet' is not assignable to type 'Gаlaxy'. Type 'Plаnet' is not assignable to type '{ __brand: "galaxy"; }'. Types of property '__brand' are incompatible. Type '"planet"' is not assignable to type '"galaxy"'.2322Type 'Plаnet' is not assignable to type 'Gаlaxy'. Type 'Plаnet' is not assignable to type '{ __brand: "galaxy"; }'. Types of property '__brand' are incompatible. Type '"planet"' is not assignable to type '"galaxy"'.= gаlaxy plаnet ;
В этом примере типы Planet
и Galaxy
никак не связаны и поэтому считаются интерпретатором совершенно разными типами, взаимное присваивание которых запрещено.
Чем отличается структурная типизация?
JavaScript является динамическим по своей природе языком программирования. Например, при передаче значения в функцию, совсем не важно как именно оно было получено — главное, чтобы оно имело все нужные поля и методы. А система типов в TypeScript была разработана с оглядкой на то как пишется типичный JavaScript код. Поэтому, чтобы покрыть всё многообразие возможностей и смоделировать аналогичное поведение, в TypeScript используется структурная типизация.
При структурной типизации проверка типов основывается не на их названиях, а на деталях их реализации. Если переменная имеет тип A
, то присвоить ей значение типа B
можно, если в типе B
есть все поля из типа A
, а типы значений этих полей совпадают, или точно так же структурно совместимы.
Понимание принципов лежащих в основе структурной типизации позволяет лучше понимать почему возникают или наоборот не возникают те или иные ошибки типизации, а так же позволяет раскрыть весь потенциал TypeScript.
Код из предыдущего примера, но теперь в TypeScript, не вызывает ошибок типизации:
typescript// Внутренняя структура типов Planet и Galaxy полностью совпадаетtypePlanet = {name : string; };typeGalaxy = {name : string; };letplanet :Planet = {name : 'Earth' };letgalaxy :Galaxy = {name : 'Milky Way' };// Поэтому TypeScript разрешает присваивание в обе стороныplanet =galaxy ;galaxy =planet ;
Несмотря на то, что типы Planet
и Galaxy
никак не связаны, TypeScript понимает, что их внутренняя структура одинакова и этого достаточно для успешного выполнения кода
Какая в этом польза?
Из предыдущего примера может быть не очевидно какую пользу приносит структурная типизация. Однако, именно структурная типизация позволяет почти полностью покрыть все возможности JavaScript. Кроме того, структурная типизация позволяет сократить количество шаблонного кода, а так же позволяет избегать нежелательных связей между разными частями системы.
Например, благодаря структурной типизации в TypeScript довольно просто реализовать функцию общего назначения, которая сможет обрабатывать переменные разных типов. Такой функции не обязательно знать о существовании этих типов — она лишь должна задекларировать интересующие её поля объекта:
typescripttypePlanet = {name : string;satellites : string[];};constplanet :Planet = {name : 'Mars',satellites : ['Phobos', 'Deimos']};typeGalaxy = {name : string;type : string;};constgalaxy :Galaxy = {name : 'Milky Way',type : 'Barred spiral galaxy'};// Функция может работать с любым объектом в котором есть поле name (в том числе Planet и Galaxy)functionlogName (value : {name : string; }) {console .log (`The name is: ${value .name }`);}logName (planet );logName (galaxy );
При этом, если переданное значение не будет содержать необходимых полей, или их типы будут отличаться от требуемых, то TypeScript предупредит нас и поможет избежать ошибок:
ts// Переданный объект не содержит поле nameArgument of type '{ satellites: string[]; }' is not assignable to parameter of type '{ name: string; }'. Object literal may only specify known properties, and 'satellites' does not exist in type '{ name: string; }'.2345Argument of type '{ satellites: string[]; }' is not assignable to parameter of type '{ name: string; }'. Object literal may only specify known properties, and 'satellites' does not exist in type '{ name: string; }'.logName ({: ['Phobos', 'Deimos'] }); satellites // Тип поля name не совпадает с требуемымType 'boolean' is not assignable to type 'string'.2322Type 'boolean' is not assignable to type 'string'.logName ({: false }); name
Структурная типизация в TypeScript — это мощный инструмент, который открывает огромное количество возможностей. Но чтобы раскрыть её полный потенциал, необходимо учитывать несколько важных особенностей.
1. Структурная типизация не защищает от всех ошибок
Так как структурная типизация опирается только на внутреннюю структуру объектов, то существует ряд сценариев, в которых TypeScript не защитит нас от потенциальных ошибок.
Представим, что мы реализуем функцию для сохранения информации в базу данных:
typescriptimport {db } from './database';typePlanet = {name : string; };functioncreatePlanet (planet :Planet ) {db .planets .insert (planet );}
Если мы перепутаем и вместо нужной информации передадим объект другого типа (пусть и схожей структуры), то TypeScript не подскажет об ошибке:
typescripttypeGalaxy = {name : string; };constgalaxy :Galaxy = {name : 'Milky Way',};// В базу данных сохранена информация об объекте другого типаcreatePlanet (galaxy );
Так же стоит учесть, что структурной типизации поддаются не только объекты, но и классы. При сравнении двух экземпляров класса, TypeScript сравнивает только их публичные методы и свойства:
typescriptclassGalaxy {name : string;constructor(name : string) {this.name =name ;}}constgalaxy = newGalaxy ('Milky Way');// В базу данных сохранена информация об объекте другого типаcreatePlanet (galaxy );
2. Все типы считаются «открытыми»
Чаще всего TypeScript не может гарантировать, что объект содержит только те поля, которые перечислены в типе. Из-за структурной типизации в переменную или функцию могут быть переданы объекты с более широким набором полей:
tsconstmars = {name : 'Mars',satellites : ['Phobos', 'Deimos'],galaxy : 'Milky Way'};typePlanet = {name : string;satellites : string[];};// В переменную с типом Planet записан объект с бóльшим количеством полейconstplanet :Planet =mars ;// TypeScript достоверно знает, что в объекте есть поля name и satellites, но не другиеtypePlanetKeys = keyof typeofplаnet ;
В примере выше переменная имеет тип Planet
, из-за чего оператор keyof typeof planet
возвращает только два ключа — name
и satellites
. Но на самом деле в объекте есть еще одно поле — galaxy
.
Поэтому, из-за того что объект может содержать значительно больше полей чем объявлено, типы в TypeScript принято называть «открытыми» или «open-ended».
Чаще всего такое поведение вызывает недопонимание при попытке использовать стандартный метод получения всех ключей объекта Object.keys()
:
tsconstplanet = {name : 'Mars',satellites : ['Phobos', 'Deimos'],};constplanetKeys =Object .keys (planet );
Переменная planetKeys
имеет тип string[]
, поэтому если мы попробуем обратиться к одному из ключей объекта, то у нас ничего не выйдет:
tsconstElement implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ name: string; satellites: string[]; }'. No index signature with a parameter of type 'string' was found on type '{ name: string; satellites: string[]; }'.7053Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ name: string; satellites: string[]; }'. No index signature with a parameter of type 'string' was found on type '{ name: string; satellites: string[]; }'.planetName =planet [planetKeys [0]];
Так происходит, потому что TypeScript не может гарантировать то, что в объекте есть только два поля. А это означает, что массив planetKeys
может содержать и другие ключи, тип значения которых для TypeScript не известен. Поэтому интерпретатору ничего не остается как использовать тип string[]
в качестве возвращаемого значения как наиболее безопасный вариант.
Подробности можно найти в этом комментарии на GitHub
3. Некоторые типы ведут себя не так как кажется на первый взгляд
Иногда, из-за структурной типизации, поведение некоторых типов может отличаться от ожидаемого. Больше всего вопросов вызывают типы {}
и Object
— на первый взгляд может показаться, что они отлично подходят для описания пустого объекта:
tstypeEmptyObject = {};constemptyObject :EmptyObject = {};
На самом же деле эти типы описывают любое значение, за исключением undefined
и null
:
tsconststringValue : {} = 'Mars';constnumberValue : {} = 37;constobjectValue : {} = {name : 'Mars' };constType 'undefined' is not assignable to type '{}'.2322Type 'undefined' is not assignable to type '{}'.: {} = undefinedValue undefined ;constType 'null' is not assignable to type '{}'.2322Type 'null' is not assignable to type '{}'.: {} = null; nullValue
tsconststringValue :Object = 'Mars';constnumberValue :Object = 37;constobjectValue :Object = {name : 'Mars' };constType 'undefined' is not assignable to type 'Object'.2322Type 'undefined' is not assignable to type 'Object'.: undefinedValue Object =undefined ;constType 'null' is not assignable to type 'Object'.2322Type 'null' is not assignable to type 'Object'.: nullValue Object = null;
Так происходит из-за того, что TypeScript использует структурную типизацию, а любое значение в JavaScript является объектом. Так как тип {}
описывает объект в котором нет никаких обязательных полей, то структурно он подходит под любой другой тип.
4. Структурная типизация не распространяется на Enumы
Если вдруг вы не знакомы с enumами, то в TypeScript они позволяют описать набор связанных именованных значений. Для их создания используется специальное ключевое слово enum
:
tsenumCardinalDirection {North = 'North',East = 'East',South = 'South',West = 'West',}// enum можно использовать как в качестве значенияconstnorth =CardinalDirection .North ;// так и в качестве типаfunctionmove (direction :CardinalDirection ) {// ...}
Такой синтаксис может быть похож на создание объекта с определенным набором полей, но enum
сильно отличается от обычных объектов. enum
— это специальная конструкция TypeScript, на которую не распространяются правила структурной типизации.
Вместо этого они используют номинативную типизацию, что означает что в расчет берется не только значение, но и название конкретного enumа. Даже полностью одинаковые по своему составу enumы считаются разными, если имеют разные названия:
tsenumCardinalDirection {North = 'North',East = 'East',South = 'South',West = 'West',}enumMovementDirection {North = 'North',East = 'East',South = 'South',West = 'West',}letcardinalDirection :CardinalDirection =CardinalDirection .North ;letmovementDirection :MovementDirection =MovementDirection .North ;// Значения полностью совпадают, но присваивание всё равно запрещеноType 'MovementDirection.North' is not assignable to type 'CardinalDirection'.2322Type 'MovementDirection.North' is not assignable to type 'CardinalDirection'.= cardinalDirection movementDirection ;// Значения полностью совпадают, но присваивание всё равно запрещеноType 'CardinalDirection' is not assignable to type 'MovementDirection'.2322Type 'CardinalDirection' is not assignable to type 'MovementDirection'.= movementDirection cardinalDirection ;
Также не получится присвоить и литеральное значение там, где ожидается enum:
tsconstType '"North"' is not assignable to type 'CardinalDirection'.2322Type '"North"' is not assignable to type 'CardinalDirection'.: direction CardinalDirection = 'North'
Подведём итоги
Структурная типизация — это одна из ключевых концепций, с которой нужно познакомиться чтобы раскрыть весь потенциал TypeScript. В этой главе мы узнали чем структурная типизация отличается от номинативной, как TypeScript сравнивает между собой разные типы, а главное заложили прочный фундамент для дальнейшего изучения более продвинутых тем.
Вспомним основные особенности структурной типизации с которыми мы познакомились:
- Структурная типизация основывается не на названиях типов, а на их внутренностях. Два типа взаимозаменяемы, если их структура совпадает
- Из-за этого проверка типов может пропускать некоторые ошибки — функция может принимать не только объекты определенного типа, но и любые другие в которых есть тот же набор полей. Будьте осторожны с похожими типами!
- В TypeScript все типы считаются «открытыми». Это означает, что объект может содержать больше полей, чем было объявлено в типе. Помните об этом используя
Object.keys
- Не используйте типы
{}
иObject
— скорее всего они ведут себя не так как вы ожидаете - Структурная типизация не распространяется на enumы. При сравнении типов TypeScript обращает внимание на их названия, даже если конкретные значения совпадают