Утверждение типов (Type Assertion)

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

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

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

В 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.

ts
// <canvas id="stage" data-inactive="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 и существует термин утверждение типа, по ходу изложения в качестве синонимов будут употребляться слова преобразование, реже — приведение. А так же, не будет лишним напомнить, что приведение — это процесс в котором объект одного типа преобразуется в объект другого типа.

Утверждение типа с помощью <Type> синтаксиса

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

ts
<ToType>FromType

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

ts
// <canvas id="stage" data-inactive="false"></canvas>

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

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

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

ts
class Bird {
    public fly(): void {}
}

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

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

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

ts
// <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.

ts
// <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.

ts
// <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 указывается между преобразуемым и типом, к которому требуется преобразовать.

ts
FromType as ToType

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

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

ts
// <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.

ts
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();
});

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

ts
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, усложняет процесс разработки, мешая компилятору проводить статический анализ кода, а также лишает себя помощи со стороны редактора кода. Когда разработчику известно к какому типу принадлежит значение, можно попросить компилятор изменить мнение о принадлежности значения к его типу с помощью механизма утверждения типов.

ts
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, не испытавшего трудностей, создаваемых выводом типов, при определении конструкций, которым предстоит проверка на принадлежность к литеральному типу.

ts
type Status = 200 | 404;
type Request = { status: Status }

let status = 200;

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

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

ts
// вывод типов видит как
let status: number = 200

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

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

ts
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 или угловых скобок <> и говорит компилятору, что значение является константным.

ts
type Status = 200 | 404;
type Request = { status: Status }

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

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

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

ts
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. Кроме того, все его поля, принадлежащие к примитивным типам, расцениваются как литеральные типы.

ts
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.

ts
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 value: string
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, наконец, появились структуры, которые справедливо было бы назвать полноценными константами, неизменяемыми ни при каких условиях. И это, отчасти, действительно так. Но дело в том, что на данный момент, принадлежность объектных и массивоподобных типов к константе зависит от значений, с которыми они ассоциированы.

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

ts
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 иммутабельный (неизменяемый) объект
let o0 = { f: { f: 100 } } as const; // {readonly 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 и o3.f.f иммутабельные (неизменяемые) объекты
let o3 = { f: { ...defaultObject } } as const; // {readonly 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 иммутабельный (неизменяемый) объект
let o6 = { f: { ...constObject } } as const; // {readonly f: {readonly f: 100}}

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

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

ts
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, а затем параметр принимаемого на вход условия.

ts
function identifier(condition: any): asserts condition {
    if (!condition) {
        throw new Error('');
    }
}

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

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

ts
// утверждение в сигнатуре
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
}

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

ts
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
}

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

ts
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(); // нет ошибки, потому, что утверждение типов не работает
}