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

Утверждение Типов (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 и hidden, которые необходимы для отображаемых объектов.

Возвращаясь к методу 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 отсутствует свойство dataset.

Но факт, что разработчику известен тип, к которому принадлежит объект по конкретному селектору, дает основания попросить компилятор пересмотреть свое отношение к типу конкретного объекта.

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

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

Утверждение типа похоже на преобразование (приведение) типов (type conversion, typecasting), за исключением того, что от них не остается и следа в скомпилированном коде, а внутренний механизм различен. Именно поэтому они и называются утверждения.

Утверждая тип, разработчик говорит компилятору — “поверь мне, я знаю, что делаю” (Trust me, I know what I'm doing).

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

Утверждение типа имеет две формы. Первый способ заключается в указании принадлежности к типу с помощью угловых скобок <Type>. Второй — с помощью оператора as.

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

Утверждение Типа <Type> синтаксис

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

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

Начиная с версии 3.4 TypeScript вводит такое понятие, как const assertion (утверждение к константе или константное утверждение).

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

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

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"

Итоги

  • Процесс, который в других языках принято называть приведение или преобразование, в TypeScript называется утверждение типа.
  • Процесс утверждения типа лишь просит компилятор пересмотреть свое отношение к типу, но конечное решение остается за ним.
  • Утверждение типа может указываться двумя способами, при помощи синтаксиса угловых скобок, в которые заключен тип (<ToType>FromType), и с помощью оператора as (FromType as ToType).
  • Код, который был создан разработчиком и требует утверждения типов, скорее всего является признаком непродуманной архитектуры.