Утверждение типов¶
Получение значения, которое не соответствует ожидаемому типу, является обычным делом для типизированных языков. Понимание причин, лежащих в основе несоответствий, а также всевозможные способы их разрешений, являются целями данной главы.
Утверждение типов - общее¶
При разработке приложений на языках со статической типизацией время от времени может возникнуть нестыковка из-за несоответствия типов. Простыми словами, приходится работать с объектом, принадлежащим к известному типу, но ограниченному более специализированным (менее конкретным) интерфейсом.
В 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
.
// <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>
, заключающих в себе конкретный тип, к которому и будет выполняться преобразование. Утверждение типа располагается строго перед выражением, результатом выполнения которого будет преобразуемый тип.
<ToType>FromType;
Перепишем предыдущий код и исправим в нем ошибку, связанную с несоответствием типов.
// <canvas id="stage" data-unactive="false"></canvas>
const element: Element = document.querySelector('#stage');
const stage: HTMLElement = <HTMLElement>element; // Ok
stage.dataset.unactive = 'true';
Если тип, к которому разработчик просит преобразовать компилятор, не совместим с преобразуемым типом, то в процессе утверждения возникнет ошибка.
class Bird {
public fly(): void {}
}
class Fish {
public swim(): void {}
}
let bird: Bird = new Bird();
let fish: Fish = <Fish>bird; // Ошибка, 'Bird' не может быть преобразован в 'Fish'
Кроме того, существуют ситуации, в которых возникает необходимость множественного последовательного преобразования. Ярким примером являются значения, полученные от dom элементов, которые воспринимаются разработчиком как числовые или логические, но по факту принадлежат к строковому типу.
// <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
.
// <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
.
// <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
указывается между преобразуемым и типом, к которому требуется преобразовать.
FromType as ToType;
Для демонстрации оператора as
рассмотрим ещё один часто встречающийся случай, требующий утверждения типов.
Обычное дело: при помощи метода querySelector()
получить объект, принадлежащий к типу HTMLElement
, и подписать его на событие click
. Задача заключается в том, что при возникновении события нужно изменить значение поля dataset
, объявленного в типе HTMLElement
. Было бы нерационально снова получать ссылку на объект при помощи метода querySelector()
, ведь нужный объект хранится в свойстве объекта события target
. Но дело в том, что свойство target
имеет тип EventTarget
, который не находится в иерархической зависимости с типом HTMLElement
, имеющим нужное свойство dataset
.
// <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
.
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();
});
В случае несовместимости типов возникнет ошибка.
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
, усложняет процесс разработки, мешая компилятору проводить статический анализ кода, а также лишает себя помощи со стороны редактора кода. Когда разработчику известно, к какому типу принадлежит значение, можно попросить компилятор изменить мнение о принадлежности значения к его типу с помощью механизма утверждения типов.
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, не испытавшего трудностей, создаваемых выводом типов при определении конструкций, которым предстоит проверка на принадлежность к литеральному типу..
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
.
// вывод типов видит как
let status: number = 200;
// в то время как требуется так
let port: 200 = 200;
Прежде всего не будет лишним упомянуть, что данную проблему можно решить с помощью механизма утверждения при помощи таких операторов как as
и угловых скобок <>
.
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
или угловых скобок <>
и говорит компилятору, что значение является константным.
type Status = 200 | 404;
type Request = { status: Status };
let status = 200 as const;
// let status = <const>200;
let request: Request = { status }; // Ok
Утверждение, что значение является константным, заставляет вывод типов расценивать его как принадлежащее к литеральному типу. Утверждение к константе массива заставляет вывод типов определять его принадлежность к типу readonly tuple
.
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
. Кроме того, все его поля, принадлежащие к примитивным типам, расцениваются как литеральные типы.
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
.
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, наконец, появились структуры, которые справедливо было бы назвать полноценными константами, не изменяемыми ни при каких условиях. И это, отчасти, действительно так. Но дело в том, что, на данный момент, принадлежность объектных и массивоподобных типов к константе зависит от значений, с которыми они ассоциированы.
В случае, когда литералы ссылочных типов (массивы и объекты) ассоциированы со значением, также принадлежащим к ссылочному типу, то они представляются такими, какими были на момент ассоциации. Кроме того, поведение механизма приведения к константе зависит от другого механизма — деструктуризации.
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;};}
По причине, что объектные типы данных, хранящиеся в массиве, подчиняются описанным выше правилам, подробное рассмотрение процесса утверждения массива к константе будет опущено.
И последнее, о чем стоит упомянуть — утверждение к константе применимо только к простым выражениям.
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
, а затем параметр принимаемого на вход условия.
function identifier(condition: any): asserts condition {
if (!condition) {
throw new Error('');
}
}
Ключевой особенностью утверждения в сигнатуре является то, что в качестве аргумента утверждающая функция ожидает выражение, определяющее принадлежность к конкретному типу с помощью любого предназначенного для этого механизма (typeof
, instanceof
и даже с помощью механизма утверждения типов, реализуемого самим TypeScript).
Если принадлежность значения к указанному типу подтверждается, то далее по коду компилятор будет рассматривать его в роли этого типа. Иначе выбрасывается исключение.
// утверждение в сигнатуре
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
};
При использовании механизма утверждения в сигнатуре с механизмом утверждения типа, условие из вызова утверждающей функции можно перенести в её тело.
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
};
Стоит обратить внимание на то, что механизм утверждения типа не будет работать в случае переноса условного выражения в тело утверждающей функции, сигнатура которой лишена утверждения типов и содержит исключительно утверждения в сигнатуре.
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(); // нет ошибки, потому что утверждение типов не работает
};