Перейти к содержанию

Утверждение типов

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

Утверждение типов - общее

При разработке приложений на языках со статической типизацией время от времени может возникнуть нестыковка из-за несоответствия типов. Простыми словами, приходится работать с объектом, принадлежащим к известному типу, но ограниченному более специализированным (менее конкретным) интерфейсом.

В TypeScript большинство операций с несоответствием типов приходится на работу с dom (Document Object Model).

В качестве примера можно рассмотреть работу с таким часто используемым методом, как querySelector(). Но для начала вспомним, что в основе составляющих иерархию dom-дерева объектов лежит базовый тип Node, наделенный минимальными признаками, необходимыми для построения коллекции. Базовый тип Node, в том числе, расширяет и тип Element, который является базовым для всех элементов dom-дерева и обладает знакомыми всем признаками, необходимыми для работы с элементами dom, такими как атрибуты (attributes), список классов (classList), размеры клиента (client*) и другими. Элементы dom-дерева можно разделить на те, что не отображаются (унаследованные от Element, как например script, link) и те, что отображаются (например div, body). Последние имеют в своей иерархии наследования тип HTMLElement, расширяющий Element, который привносит признаки, присущие отображаемым объектам, как, например, координаты, стили, свойство dataset и т.д.

Возвращаясь к методу querySelector(), стоит уточнить, что результатом его вызова может стать любой элемент, находящийся в dom-дереве. Если бы в качестве типа возвращаемого значения был указан тип HTMLElement, то операция получения элемента <script> или <link> завершилась бы неудачей, так как они не принадлежат к этому типу. Именно поэтому методу querySelector() в качестве типа возвращаемого значения указан более базовый тип Element.

1
2
3
4
// <canvas id="stage" data-unactive="false"></canvas>

const element: Element = document.querySelector('#stage');
const stage: HTMLElement = element; // Error, Element is not assignable to type HTMLElement

Но при попытке обратиться к свойству dataset через объект, полученный с помощью querySelector(), возникнет ошибка, так как у типа Element отсутствует данное свойство. Факт, что разработчику известен тип, к которому принадлежит объект по указанному им селектору, дает ему основания попросить вывод типов пересмотреть свое отношение к типу конкретного объекта.

Попросить - дословно означает, что разработчик может лишь попросить вывод типов пересмотреть отношение к типу. Но решение разрешить операцию или нет все равно остается за последним.

Выражаясь человеческим языком, в TypeScript процесс, вынуждающий вывод типов пересмотреть свое отношение к какому-либо типу называется утверждением типа (Type Assertion).

Формально утверждение типа похоже на преобразование (приведение) типов (type conversion, typecasting) но, поскольку в скомпилированном коде от типов не остается и следа, то по факту это совершенно другой механизм. Именно поэтому он и называется утверждение. Утверждая тип, разработчик говорит компилятору — “поверь мне, я знаю, что делаю” (Trust me, I know what I'm doing).

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

Утверждение типа синтаксис

Одним из способов указать компилятору на принадлежность значения к заданному типу является механизм утверждения типа при помощи угловых скобок <ConcreteType>, заключающих в себе конкретный тип, к которому и будет выполняться преобразование. Утверждение типа располагается строго перед выражением, результатом выполнения которого будет преобразуемый тип.

1
<ToType>FromType;

Перепишем предыдущий код и исправим в нем ошибку, связанную с несоответствием типов.

1
2
3
4
5
6
// <canvas id="stage" data-unactive="false"></canvas>

const element: Element = document.querySelector('#stage');

const stage: HTMLElement = <HTMLElement>element; // Ok
stage.dataset.unactive = 'true';

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Bird {
  public fly(): void {}
}

class Fish {
  public swim(): void {}
}

let bird: Bird = new Bird();
let fish: Fish = <Fish>bird; // Ошибка, 'Bird' не может быть преобразован в 'Fish'

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

1
2
3
4
5
6
7
// <div id="#container"></div>

let element = document.querySelector(
  '#container'
) as HTMLElement;
let { width, height } = element.style;
let area: number = width * height; // ошибка -> width и height типа 'string'

Дело в том, что в TypeScript невозможно привести тип string к типу number.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// <div id="#container"></div>

let element = document.querySelector(
  '#container'
) as HTMLElement;
let {
  width: widthString,
  height: heightString,
} = element.style;

let width: number = <number>widthString; // Ошибка -> тип 'string' не может быть преобразован  в 'number'
let height: number = <number>heightString; // Ошибка -> тип 'string' не может быть преобразован  в 'number'

Но осуществить задуманное можно преобразовав тип string сначала в тип any, а уже затем — в тип number.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// <div id="#container"></div>

let element = document.querySelector(
  '#container'
) as HTMLElement;
let {
  width: widthString,
  height: heightString,
} = element.style;

let width: number = <number>(<any>widthString); // Ok
let height: number = <number>(<any>heightString); // Ok

let area: number = width * height; // Ok

Стоит также заметить, что данный способ утверждения типа, кроме синтаксиса, больше ничем не отличается от указания с помощью оператора as.

Утверждение типа с помощью оператора as

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

1
FromType as ToType;

Для демонстрации оператора as рассмотрим ещё один часто встречающийся случай, требующий утверждения типов.

Обычное дело: при помощи метода querySelector() получить объект, принадлежащий к типу HTMLElement, и подписать его на событие click. Задача заключается в том, что при возникновении события нужно изменить значение поля dataset, объявленного в типе HTMLElement. Было бы нерационально снова получать ссылку на объект при помощи метода querySelector(), ведь нужный объект хранится в свойстве объекта события target. Но дело в том, что свойство target имеет тип EventTarget, который не находится в иерархической зависимости с типом HTMLElement, имеющим нужное свойство dataset.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// <span id="counter"></span>

let element = document.querySelector(
  '#counter'
) as HTMLElement;
element.dataset.count = (0).toString();

element.addEventListener('click', ({ target }) => {
  let count: number = target.dataset.count; // Error -> Property 'dataset' does not exist on type 'EventTarget'
});

Но эту проблему легко решить с помощью оператора утверждения типа as. Кроме того, с помощью этого же оператора можно привести тип string, к которому принадлежат все свойства, находящиеся в dataset, к типу any, а уже затем к типу number.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let element = document.querySelector(
  '#counter'
) as HTMLElement;
element.dataset.count = (0).toString();

element.addEventListener('click', ({ target }) => {
  let element = target as HTMLElement;
  let count: number = (element.dataset
    .count as any) as number;

  element.dataset.count = (++count).toString();
});

В случае несовместимости типов возникнет ошибка.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Bird {
  public fly(): void {}
}

class Fish {
  public swim(): void {}
}

let bird: Bird = new Bird();
let fish: Fish = bird as Fish; // Ошибка, 'Bird' не может быть преобразован в 'Fish'

Ещё одна острая необходимость, требующая утверждения типа, возникает тогда, когда разработчику приходится работать с объектом, ссылка на который ограничена более общим типом, как например any.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class DataProvider {
  constructor(readonly data: any) {}
}

let provider: DataProvider = new DataProvider('text');

var charAll: string[] = provider.data.split(''); // Ок
var charAll: string[] = provider.data.sPlIt(''); // Ошибка во время выполнения программы
var charAll: string[] = (provider.data as string).split(''); // Ок

let dataString: string = provider.data as string;
var charAll: string[] = dataString.split(''); // Ок

Напоследок, стоит сказать, что выражения, требующие утверждения типа при работе с dom api — это неизбежность. Кроме того, для работы с методом document.querySelector(), который был использован в примерах к этой главе, вместо приведения типов с помощью операторов <Type> или as предпочтительней конкретизировать тип с помощью обобщения, которое рассматривается в главе Обобщения (Generics). Но в случае, если утверждение требуется для кода, написанного самим разработчиком, то скорее всего, это следствие плохо продуманной архитектуры.

Приведение (утверждение) к константе (const assertion)

Ни для кого не секрет, что с точки зрения JavaScript, а, следовательно, и TypeScript, все примитивные литеральные значения являются константными значениями. С точки зрения среды исполнения два эквивалентных литерала любого литерального типа являются единым значением. То есть, среда исполнения расценивает два строковых литерала 'text' и 'text' как один литерал. То же справедливо и для остальных литералов, к которым помимо типа string также относятся типы number, boolean и symbol.

Тем не менее сложно найти разработчика TypeScript, не испытавшего трудностей, создаваемых выводом типов при определении конструкций, которым предстоит проверка на принадлежность к литеральному типу..

1
2
3
4
5
6
type Status = 200 | 404;
type Request = { status: Status };

let status = 200;

let request: Request = { status }; // Error, Type 'number' is not assignable to type 'Status'.ts(2322)

В коде выше ошибка возникает по причине того, что вывод типов определяет принадлежность значения переменной status к типу number, а не литеральному числовому типу 200.

1
2
3
4
5
// вывод типов видит как
let status: number = 200;

// в то время как требуется так
let port: 200 = 200;

Прежде всего не будет лишним упомянуть, что данную проблему можно решить с помощью механизма утверждения при помощи таких операторов как as и угловых скобок <>.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type Status = 200 | 404;
type Request = { status: Status };

let status = 200;

// утверждаем компилятору..
let request: Request = { status: status as 200 }; // ...с помощью as оператора
let request: Request = { status: <200>status }; // ...с помощью угловых скобок,
// ...что он должен рассматривать значение, ассоциированное с as,
// как значение, принадлежащие к литеральному типу '200'

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

Константное утверждение производится с помощью оператора as или угловых скобок <> и говорит компилятору, что значение является константным.

1
2
3
4
5
6
7
type Status = 200 | 404;
type Request = { status: Status };

let status = 200 as const;
// let status = <const>200;

let request: Request = { status }; // Ok

Утверждение, что значение является константным, заставляет вывод типов расценивать его как принадлежащее к литеральному типу. Утверждение к константе массива заставляет вывод типов определять его принадлежность к типу readonly tuple.

1
2
3
4
let a = [200, 404]; // let a: number[]

let b = [200, 404] as const; // let b: readonly [200, 404]
let c = <const>[200, 404]; // let c: readonly [200, 404]

В случае с объектным типом, утверждение к константе рекурсивно помечает все его поля как readonly. Кроме того, все его поля, принадлежащие к примитивным типам, расцениваются как литеральные типы.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type NotConstResponseType = {
  status: number;
  data: {
    role: string;
  };
};

type ConstResponseType = {
  status: 200 | 404;
  data: {
    role: 'user' | 'admin';
  };
};

let a = { status: 200, data: { role: 'user' } }; // NotConstResponseType

let b = { status: 200, data: { role: 'user' } } as const; // ConstResponseType
let c = <const>{ status: 200, data: { role: 'user' } }; // ConstResponseType

Но стоит помнить, что утверждение к константе применимо исключительно к литералам таких типов, как number, string, boolean, array и object.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
let a = 'value' as const; // Ok - 'value' является литералом, let a: "value"
let b = 100 as const; // Ok - 100 является литералом, let b: 100
let c = true as const; // Ok - true является литералом, let c: true

let d = [] as const; // Ok - [] является литералом, let d: readonly []
let e = { f: 100 } as const; // Ok - {} является литералом, let e: {readonly f: 100}

let value = 'value';
let array = [0, 1, 2]; // let array: number[]
let object = { f: 100 }; // let object: {f: number}

let f = value as const; // Ошибка - value это ссылка на идентификатор, хранящий литерал
let g = array as const; // Ошибка - array это ссылка на идентификатор, хранящий ссылку на объект массива
let h = object as const; // Ошибка - object это ссылка на идентификатор, хранящий ссылку на объект объекта

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

В случае, когда литералы ссылочных типов (массивы и объекты) ассоциированы со значением, также принадлежащим к ссылочному типу, то они представляются такими, какими были на момент ассоциации. Кроме того, поведение механизма приведения к константе зависит от другого механизма — деструктуризации.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
let defaultObject = { f: 100 }; // let defaultObject: {f: number;}
let constObject = { f: 100 } as const; // let constObject: {readonly f: 100;}

let defaultArray = [0, 1, 2]; // let defaultArray: number[]
let constArray = [0, 1, 2] as const; // let constArray: readonly [0, 1, 2]

// неожиданно - o0.f не имеет модификатора readonly! ожидаемо - o0.f.f иммутабельный (неизменяемый) объект
let o0 = { f: { f: 100 } } as const; // {f: {readonly f: 100;};}
// ожидаемо - o1.f имеет модификатор readonly. возможно ожидаемо - o1.f.f мутабельный (изменяемый) объект
let o1 = { f: defaultObject } as const; // {readonly f: {f: number;};}
// ожидаемо - o2 иммутабельный (неизменяемый) объект
let o2 = { ...defaultObject } as const; // {readonly f: number;}
// неожиданно - o3.f не имеет модификатора readonly. ожидаемо - o3.f.f иммутабельный (неизменяемый) объект
let o3 = { f: { ...defaultObject } } as const; // {f: {readonly f: number;};}

// ожидаемо - o4.f и o4.f.f иммутабельные (неизменяемые) объекты
let o4 = { f: constObject } as const; // let o4: {readonly f: {readonly f: 100;};}
// ожидаемо - o5 иммутабельный (неизменяемый)  объект
let o5 = { ...constObject } as const; // let o5: {readonly f: 100;}
// неожиданно - o6.f не имеет модификатора readonly. ожидаемо - o6.f.f иммутабельный (неизменяемый) объект
let o6 = { f: { ...constObject } } as const; // {f: {readonly f: 100;};}

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

И последнее, о чем стоит упомянуть — утверждение к константе применимо только к простым выражениям.

1
2
3
4
5
6
let a = (Math.round(Math.random() * 1)
  ? 'yes'
  : 'no') as const; // Ошибка
let b = Math.round(Math.random() * 1)
  ? ('yes' as const)
  : ('no' as const); // Ok, let b: "yes" | "no"

Утверждение в сигнатуре (Signature Assertion)

Помимо функций, реализующих механизм утверждения типа, в TypeScript существует механизм утверждения в сигнатуре, позволяющий определять утверждающие функции, вызов которых, в случае невыполнения условия, приводит к выбрасыванию исключения. Для того чтобы объявить утверждающую функцию, в её сигнатуре (там, где располагается возвращаемое значение) следует указать ключевое слово asserts, а затем параметр принимаемого на вход условия.

1
2
3
4
5
function identifier(condition: any): asserts condition {
  if (!condition) {
    throw new Error('');
  }
}

Ключевой особенностью утверждения в сигнатуре является то, что в качестве аргумента утверждающая функция ожидает выражение, определяющее принадлежность к конкретному типу с помощью любого предназначенного для этого механизма (typeof, instanceof и даже с помощью механизма утверждения типов, реализуемого самим TypeScript).

Если принадлежность значения к указанному типу подтверждается, то далее по коду компилятор будет рассматривать его в роли этого типа. Иначе выбрасывается исключение.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// утверждение в сигнатуре
function isStringAssert(condition: any): asserts condition {
  if (!condition) {
    throw new Error(``);
  }
}

// утверждение типа
function isString(value: any): value is string {
  return typeof value === 'string';
}

const testScope = (text: any) => {
  text.touppercase(); // до утверждения расценивается как тип any..

  isStringAssert(text instanceof String); // выражение с оператором instanceof
  isStringAssert(typeof text === 'string'); // выражение с оператором typeof
  isStringAssert(isString(text)); // механизм "утверждения типа"

  text.touppercase(); // ..после утверждения как тип string
};

При использовании механизма утверждения в сигнатуре с механизмом утверждения типа, условие из вызова утверждающей функции можно перенести в её тело.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function isStringAsserts(
  value: any
): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error(``);
  }
}

const testScope = (text: any) => {
  text.touppercase(); // не является ошибкой, потому что тип - any

  isStringAsserts(text); // условие определено внутри утверждающей функции

  text.touppercase(); // теперь ошибка, потому что тип утвержден как string
};

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function isStringAsserts(
  value: any
): asserts value /** is string */ {
  if (typeof value !== 'string') {
    throw new Error(``);
  }
}

const testScope = (text: any) => {
  text.touppercase(); // не является ошибкой, потому что тип - any

  isStringAsserts(text); // условие определено в утверждающей функции

  text.touppercase(); // нет ошибки, потому что утверждение типов не работает
};

Комментарии