Type<Universe>

1.
Разбираемся со структурной типизацией

Любой типизированный язык программирования призван защищать нас от потенциальных ошибок ещё на этапе разработки. Каждый раз при присваивании значения переменной или передаче аргумента в функцию, интерпретатору приходится сравнивать между собой разные типы. Чем он руководствуется при этом сравнении?

Ответ на этот вопрос зависит от языка программирования. Все типизированные языки программирования делятся на два класса — языки с номинативной типизацией и языки со структурной типизацией. В первой главе мы разберемся чем один подход отличается от другого, а главное поймем как все устроено в TypeScript.

Что такое номинативная типизация?

В языках с номинативной типизацией особое значение имеют названия типов и их иерархия. В таких языках если переменная имеет тип A, то присвоить ей значение типа B можно только в двух случаях:

  1. Если тип B является потомком типа A,
  2. Или если тип B это и есть сам тип A

Несмотря на то, что типы Planet и Galaxy полностью совпадают, следующий код в языках с номинативной типизацией приведет к ошибкам:

typescript
// Внутренняя структура типов Planet и Galaxy полностью совпадает
type Planet = { name: string; };
type Galaxy = { name: string; };
 
let planet: Planet = { name: 'Earth' };
let galaxy: Galaxy = { name: 'Milky Way' };
 
// Но несмотря на это, такое присваивание запрещено
plаnet = gаlaxy;
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"'.
 
gаlaxy = plаnet;
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"'.

В этом примере типы Planet и Galaxy никак не связаны и поэтому считаются интерпретатором совершенно разными типами, взаимное присваивание которых запрещено.

Чем отличается структурная типизация?

JavaScript является динамическим по своей природе языком программирования. Например, при передаче значения в функцию, совсем не важно как именно оно было получено — главное, чтобы оно имело все нужные поля и методы. А система типов в TypeScript была разработана с оглядкой на то как пишется типичный JavaScript код. Поэтому, чтобы покрыть всё многообразие возможностей и смоделировать аналогичное поведение, в TypeScript используется структурная типизация.

При структурной типизации проверка типов основывается не на их названиях, а на деталях их реализации. Если переменная имеет тип A, то присвоить ей значение типа B можно, если в типе B есть все поля из типа A, а типы значений этих полей совпадают, или точно так же структурно совместимы.

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

Код из предыдущего примера, но теперь в TypeScript, не вызывает ошибок типизации:

typescript
// Внутренняя структура типов Planet и Galaxy полностью совпадает
type Planet = { name: string; };
type Galaxy = { name: string; };
 
let planet: Planet = { name: 'Earth' };
let galaxy: Galaxy = { name: 'Milky Way' };
 
// Поэтому TypeScript разрешает присваивание в обе стороны
planet = galaxy;
galaxy = planet;

Несмотря на то, что типы Planet и Galaxy никак не связаны, TypeScript понимает, что их внутренняя структура одинакова и этого достаточно для успешного выполнения кода

Какая в этом польза?

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

Например, благодаря структурной типизации в TypeScript довольно просто реализовать функцию общего назначения, которая сможет обрабатывать переменные разных типов. Такой функции не обязательно знать о существовании этих типов — она лишь должна задекларировать интересующие её поля объекта:

typescript
type Planet = {
name: string;
satellites: string[];
};
const planet: Planet = {
name: 'Mars',
satellites: ['Phobos', 'Deimos']
};
 
type Galaxy = {
name: string;
type: string;
};
const galaxy: Galaxy = {
name: 'Milky Way',
type: 'Barred spiral galaxy'
};
 
// Функция может работать с любым объектом в котором есть поле name (в том числе Planet и Galaxy)
function logName(value: { name: string; }) {
console.log(`The name is: ${value.name}`);
}
 
logName(planet);
logName(galaxy);

При этом, если переданное значение не будет содержать необходимых полей, или их типы будут отличаться от требуемых, то TypeScript предупредит нас и поможет избежать ошибок:

ts
// Переданный объект не содержит поле name
logName({ satellites: ['Phobos', 'Deimos'] });
Argument 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; }'.
 
// Тип поля name не совпадает с требуемым
logName({ name: false });
Type 'boolean' is not assignable to type 'string'.2322Type 'boolean' is not assignable to type 'string'.

Структурная типизация в TypeScript — это мощный инструмент, который открывает огромное количество возможностей. Но чтобы раскрыть её полный потенциал, необходимо учитывать несколько важных особенностей.

1. Структурная типизация не защищает от всех ошибок

Так как структурная типизация опирается только на внутреннюю структуру объектов, то существует ряд сценариев, в которых TypeScript не защитит нас от потенциальных ошибок.

Представим, что мы реализуем функцию для сохранения информации в базу данных:

typescript
import { db } from './database';
 
type Planet = { name: string; };
 
function createPlanet(planet: Planet) {
db.planets.insert(planet);
}

Если мы перепутаем и вместо нужной информации передадим объект другого типа (пусть и схожей структуры), то TypeScript не подскажет об ошибке:

typescript
type Galaxy = { name: string; };
 
const galaxy: Galaxy = {
name: 'Milky Way',
};
 
// В базу данных сохранена информация об объекте другого типа
createPlanet(galaxy);

Так же стоит учесть, что структурной типизации поддаются не только объекты, но и классы. При сравнении двух экземпляров класса, TypeScript сравнивает только их публичные методы и свойства:

typescript
class Galaxy {
name: string;
 
constructor(name: string) {
this.name = name;
}
}
 
const galaxy = new Galaxy('Milky Way');
 
// В базу данных сохранена информация об объекте другого типа
createPlanet(galaxy);

2. Все типы считаются «открытыми»

Чаще всего TypeScript не может гарантировать, что объект содержит только те поля, которые перечислены в типе. Из-за структурной типизации в переменную или функцию могут быть переданы объекты с более широким набором полей:

ts
const mars = {
name: 'Mars',
satellites: ['Phobos', 'Deimos'],
galaxy: 'Milky Way'
};
 
type Planet = {
name: string;
satellites: string[];
};
 
// В переменную с типом Planet записан объект с бóльшим количеством полей
const planet: Planet = mars;
 
// TypeScript достоверно знает, что в объекте есть поля name и satellites, но не другие
type PlanetKeys = keyof typeof plаnet;
type PlanetKeys = "name" | "satellites"

В примере выше переменная имеет тип Planet, из-за чего оператор keyof typeof planet возвращает только два ключа — name и satellites. Но на самом деле в объекте есть еще одно поле — galaxy.

Поэтому, из-за того что объект может содержать значительно больше полей чем объявлено, типы в TypeScript принято называть «открытыми» или «open-ended».

Чаще всего такое поведение вызывает недопонимание при попытке использовать стандартный метод получения всех ключей объекта Object.keys():

ts
const planet = {
name: 'Mars',
satellites: ['Phobos', 'Deimos'],
};
 
const planetKeys = Object.keys(planet);
const planetKeys: string[]

Переменная planetKeys имеет тип string[], поэтому если мы попробуем обратиться к одному из ключей объекта, то у нас ничего не выйдет:

ts
const planetName = planet[planetKeys[0]];
Element 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[]; }'.

Так происходит, потому что TypeScript не может гарантировать то, что в объекте есть только два поля. А это означает, что массив planetKeys может содержать и другие ключи, тип значения которых для TypeScript не известен. Поэтому интерпретатору ничего не остается как использовать тип string[] в качестве возвращаемого значения как наиболее безопасный вариант.

Подробности можно найти в этом комментарии на GitHub

3. Некоторые типы ведут себя не так как кажется на первый взгляд

Иногда, из-за структурной типизации, поведение некоторых типов может отличаться от ожидаемого. Больше всего вопросов вызывают типы {} и Object — на первый взгляд может показаться, что они отлично подходят для описания пустого объекта:

ts
type EmptyObject = {};
 
const emptyObject: EmptyObject = {};

На самом же деле эти типы описывают любое значение, за исключением undefined и null:

ts
const stringValue: {} = 'Mars';
const numberValue: {} = 37;
const objectValue: {} = { name: 'Mars' };
 
const undefinedValue: {} = undefined;
Type 'undefined' is not assignable to type '{}'.2322Type 'undefined' is not assignable to type '{}'.
 
const nullValue: {} = null;
Type 'null' is not assignable to type '{}'.2322Type 'null' is not assignable to type '{}'.
ts
const stringValue: Object = 'Mars';
const numberValue: Object = 37;
const objectValue: Object = { name: 'Mars' };
 
const undefinedValue: Object = undefined;
Type 'undefined' is not assignable to type 'Object'.2322Type 'undefined' is not assignable to type 'Object'.
 
const nullValue: Object = null;
Type 'null' is not assignable to type 'Object'.2322Type 'null' is not assignable to type 'Object'.

Так происходит из-за того, что TypeScript использует структурную типизацию, а любое значение в JavaScript является объектом. Так как тип {} описывает объект в котором нет никаких обязательных полей, то структурно он подходит под любой другой тип.

4. Структурная типизация не распространяется на Enumы

Если вдруг вы не знакомы с enumами, то в TypeScript они позволяют описать набор связанных именованных значений. Для их создания используется специальное ключевое слово enum:

ts
enum CardinalDirection {
North = 'North',
East = 'East',
South = 'South',
West = 'West',
}
 
// enum можно использовать как в качестве значения
const north = CardinalDirection.North;
 
// так и в качестве типа
function move(direction: CardinalDirection) {
// ...
}

Такой синтаксис может быть похож на создание объекта с определенным набором полей, но enum сильно отличается от обычных объектов. enum — это специальная конструкция TypeScript, на которую не распространяются правила структурной типизации.

Вместо этого они используют номинативную типизацию, что означает что в расчет берется не только значение, но и название конкретного enumа. Даже полностью одинаковые по своему составу enumы считаются разными, если имеют разные названия:

ts
enum CardinalDirection {
North = 'North',
East = 'East',
South = 'South',
West = 'West',
}
 
enum MovementDirection {
North = 'North',
East = 'East',
South = 'South',
West = 'West',
}
 
let cardinalDirection: CardinalDirection = CardinalDirection.North;
let movementDirection: MovementDirection = MovementDirection.North;
 
// Значения полностью совпадают, но присваивание всё равно запрещено
cardinalDirection = movementDirection;
Type 'MovementDirection.North' is not assignable to type 'CardinalDirection'.2322Type 'MovementDirection.North' is not assignable to type 'CardinalDirection'.
 
// Значения полностью совпадают, но присваивание всё равно запрещено
movementDirection = cardinalDirection;
Type 'CardinalDirection' is not assignable to type 'MovementDirection'.2322Type 'CardinalDirection' is not assignable to type 'MovementDirection'.

Также не получится присвоить и литеральное значение там, где ожидается enum:

ts
const direction: CardinalDirection = 'North'
Type '"North"' is not assignable to type 'CardinalDirection'.2322Type '"North"' is not assignable to type 'CardinalDirection'.

Подведём итоги

Структурная типизация — это одна из ключевых концепций, с которой нужно познакомиться чтобы раскрыть весь потенциал TypeScript. В этой главе мы узнали чем структурная типизация отличается от номинативной, как TypeScript сравнивает между собой разные типы, а главное заложили прочный фундамент для дальнейшего изучения более продвинутых тем.

Вспомним основные особенности структурной типизации с которыми мы познакомились:

  1. Структурная типизация основывается не на названиях типов, а на их внутренностях. Два типа взаимозаменяемы, если их структура совпадает
  2. Из-за этого проверка типов может пропускать некоторые ошибки — функция может принимать не только объекты определенного типа, но и любые другие в которых есть тот же набор полей. Будьте осторожны с похожими типами!
  3. В TypeScript все типы считаются «открытыми». Это означает, что объект может содержать больше полей, чем было объявлено в типе. Помните об этом используя Object.keys
  4. Не используйте типы {} и Object — скорее всего они ведут себя не так как вы ожидаете
  5. Структурная типизация не распространяется на enumы. При сравнении типов TypeScript обращает внимание на их названия, даже если конкретные значения совпадают
Подпишись и узнавай обо всём первым
Будет приходить несколько писем в месяц, а отписаться можно в любой момент