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

Типизация в TypeScript

Самое время взять паузу и рассмотреть типизацию в TypeScript более детально через призму полученных знаний.

Итак, что известно о TypeScript? TypeScript это язык:

  1. Статически типизированный с возможностью динамического связывания
  2. Сильно типизированный
  3. Явно типизированный с возможностью вывода типов
  4. Совместимость типов в TypeScript проходит по правилам структурной типизации
  5. Совместимость типов зависит от вариантности, чей конкретный вид определяется конкретным случаем

Кроме этого, существуют понятия, которые входят в уже упомянутые, но в TypeScript вынесены в отдельные определения. Именно поэтому они будут рассматриваться отдельно. Такими понятиями являются:

  1. Наилучший общий тип
  2. Контекстный тип

Начнем с повторения определений в том порядке, в котором они были перечислены.

Статическая типизация (static typing)

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

Сильная типизация (strongly typed)

Язык с сильной типизацией не позволяет операции с несовместимыми типами данных, а также не выполняет явного преобразования типов.

Явно типизированный (explicit typing) с выводом типов (type inference)

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

Вывод типов — это возможность компилятора (интерпретатора) самостоятельно выводить-указывать тип данных на основе анализа выражения.

Совместимость типов (Type Compatibility), структурная типизация (structural typing)

Совместимость типов — это механизм, по которому происходит сравнение типов.

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

Правила совместимости типов делятся на три вида, один из которых имеет название структурная типизация.

Структурная Типизация - это принцип, определяющий совместимость типов, основываясь не на иерархии наследования или явной реализации интерфейсов, а на их описании.

С определениями закончили, осталось закрепить конкретными примерами.

[ 1 ] Сильная типизация в TypeScript проявляет себя в случаях, схожих с операцией сложения числа с массивом. В этом случае компилятор выбрасывает ошибки.

const value = 5 + [] // Error

[ 2 ] Статическая типизация в TypeScript проявляется в том, что к моменту окончания компиляции компилятор уже знает, к какому конкретному типу принадлежат все конструкции, нуждающиеся в аннотации типа.

[ 3 ] В TypeScript, если тип не указывается явно, компилятор с помощью вывода типов выводит и указывает тип самостоятельно.

var animal: Animal = new Animal() // animal: Animal
var animal = new Animal() // animal: Animal

[ 4 ] Несмотря на то, что Bird и Fish не имеют явно заданного общего предка, TypeScript разрешает присваивать экземпляр класса Fish переменной с типом Bird (и наоборот).

class Bird {
  name
}
class Fish {
  name
}

var bird: Bird = new Fish()
var fish: Fish = new Bird()

В таких языках, как Java или C#, такое поведение недопустимо. В TypeScript это становится возможно из-за структурной типизации.

Так как совместимость типов происходит на основе описания типов, то в первом случае компилятор запоминает все члены типа Fish и, если он находит аналогичные члены в типе Bird, типы считаются совместимы. То же самое компилятор проделывает тогда, когда во втором случае присваивает экземпляр класса Bird переменной с типом Fish. Так как оба типа имеют по одному полю, с одинаковым типом и идентификатором, то они считаются совместимыми.

Если добавить классу Bird поле wings, то при попытке присвоить его экземпляр переменной с типом Fish возникнет ошибка, так как в типе Fish отсутствует после wings. Обратное действие, то есть присвоение экземпляра класса Bird переменной с типом Fish, ошибки не вызовет, так как в типе Bird будут найдены все члены, объявленные в типе Fish.

class Bird {
  name
  wings
}
class Fish {
  name
}

var bird: Bird = new Fish() // Error
var fish: Fish = new Bird()

Стоит добавить, что правилам структурной типизации подчиняются все объекты в TypeScript. А, как известно, в JavaScript все, кроме примитивных типов, объекты. Это же утверждение верно и для TypeScript.

С первыми четырьмя пунктами разобрались. Двигаемся дальше.

Вариантность (variance)

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

Ковариантность позволяет большему типу быть совместимым с меньшими типом.

interface IAnimal {
  type: string
}

interface IBird extends IAnimal {
  fly(): void
}

function f0(): IAnimal {
  const v: IAnimal = {
    type: 'animal',
  }

  return v
}

function f1(): IBird {
  const v: IBird = {
    type: 'bird',
    fly() {},
  }

  return v
}

type T0 = typeof f0
type T1 = typeof f1

let v0: T0 = f1 // Ok
let v1: T1 = f0 // Error

Контравариантность позволяет меньшему типу быть совместимым с большим типом.

interface IAnimal {
  type: string
}

interface IBird extends IAnimal {
  fly(): void
}

function f0(p: IAnimal): void {}
function f1(p: IBird): void {}

type T0 = typeof f0
type T1 = typeof f1

let v0: T0 = f1 // Error
let v1: T1 = f0 // Ok

Бивариантность, доступная исключительно для параметров функций при условии, что флаг --strictFunctionTypes установлен в значение false, делает возможной совместимость как большего типа с меньшим, так и наоборот — меньшего с большим.

interface IAnimal {
  type: string
}

interface IBird extends IAnimal {
  fly(): void
}

function f0(p: IAnimal): void {}
function f1(p: IBird): void {}

type T0 = typeof f0
type T1 = typeof f1

let v0: T0 = f1 // Ok, (--strictFunctionTypes === false)
let v1: T1 = f0 // Ok

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

Наилучший общий тип (Best common type)

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

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

По легенде, существуют два класса Elephant и Lion, которые расширяют класс Animal. И экземпляры всех трех классов хранятся в массиве, ссылку на который присваивается переменной.

class Animal {}
class Elephant extends Animal {}
class Lion extends Animal {}

const animalAll = [new Elephant(), new Lion(), new Animal()] // animalAll: Elephant[]

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

Если типу Elephant будет добавлено поле, например, хобот (trunk), что сделает его отличным от всех, то вывод типов будет вынужден указать массиву базовый для всех типов тип данных Animal.

class Animal {}
class Elephant extends Animal {
  thrunk
}
class Lion extends Animal {}

const animalAll = [new Elephant(), new Lion(), new Animal()] // animalAll: Animal[]

В случае, если в массиве не будет присутствовать базовый для всех типов тип Animal, то вывод типов укажет в качестве типа массива тип Lion. Сделает он это потому что из двух типов, присутствующих в массиве, тип Lion, совместим с каждым из них. Тип Lion считается совместимым с типом Elephant потому, что все члены описанные в типе Lion присутствуют в типе Elephant. В данном случае в типе Lion вообще не описано ни одного члена. А то что вывод типов не привел массив, как ранее, к базовому типу Animal, наглядно иллюстрирует то, что вывод типов делает заключение на основе анализа конкретного выражения. В данном случае выражением является литерал массива, в который заключены два типа Elephant и Lion. Базового типа Animal в этом выражении нет, и поэтому вывод типов не берет его в расчет.

class Animal {}
class Elephant extends Animal {
  thrunk
}
class Lion extends Animal {}

let animalAll = [new Elephant(), new Lion()] // animalAll: Lion[]

После того, как типу Lion тоже будет добавлено уникальное поле, скажем, грива (mane), выводу типов ничего не останется, кроме как указать в качестве типа массива тип объединение (union), состоящий из типов Elephant и Lion.

class Animal {}
class Elephant extends Animal {
  thrunk
}
class Lion extends Animal {
  mane
}

let animalAll = [new Elephant(), new Lion()] // animalAll: (Elephant | Lion)[]

Как видно, ничего неожиданного или сложного в теме наилучшего общего типа совершенно нет.

Контекстный тип (Contextual Type)

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

Лучшим примером контекстного типа может служить подписка document на событие мыши mousedown. Так как у слушателя события, тип параметра event не указан явно, а также ему в момент объявления не было присвоено значение, то, как мы уже знаем, вывод типов должен был указать тип any. Но в данном случае компилятор указывает тип MouseEvent, потому что именно он указан в декларации типа слушателя событий. В случае подписания document на событие keydown, компилятор указывает тип как KeyboardEvent.

document.addEventListener('mousedown', (event) => {}) // event: MouseEvent
document.addEventListener('keydown', (event) => {}) // event: KeyboardEvent

Для того чтобы понять, как это работает, опишем случай из жизни зоопарка, а именно — представление с морским львом. Для этого создадим класс морской лев SeaLion и объявим в нем два метода: вращаться (rotate) и голос (voice).

class SeaLion {
  rotate(): void {}
  voice(): void {}
}

Далее, создадим класс дрессировщик Trainer и объявим в нем метод addEventListener с двумя параметрами: type с типом string и handler с типом Function.

class Trainer {
  addEventListener(type: string, handler: Function) {}
}

Затем объявим два класса события, выражающие команды дрессировщика RotateTrainerEvent и VoiceTrainerEvent.

class RotateTrainerEvent {}
class VoiceTrainerEvent {}

После объявим два псевдонима (type) для литеральных типов string. Первому зададим имя RotateEventType и в качестве значения присвоим строковой литерал "rotate". Второму зададим имя VoiceEventType и в качестве значения присвоим строковой литерал "voice".

type RotateEventType = 'rotate'
type VoiceEventType = 'voice'

Теперь осталось только задекларировать ещё два псевдонима типов для функциональных типов, у обоих из которых будет один параметр event и отсутствовать возвращаемое значение. Первому псевдониму зададим имя RotateTrainerHandler, а его параметру установим тип RotateTrainerEvent. Второму псевдониму зададим имя VoiceTrainerHandler, а его параметру установим тип VoiceTrainerEvent.

type RotateTrainerHandler = (event: RotateTrainerEvent) => void
type VoiceTrainerHandler = (event: VoiceTrainerEvent) => void

Соберём части воедино. Для этого в классе дрессировщик Trainer перегрузим метод addEventListener. У первого перегруженного метода параметр type будет иметь тип RotateEventType, а параметру handler укажем тип RotateTrainerHandler. Второму перегруженному методу в качестве типа параметра type укажем VoiceEventType, а параметру handler укажем тип VoiceTrainerHandler.

class Trainer {
  addEventListener(type: RotateEventType, handler: RotateTrainerHandler)
  addEventListener(type: VoiceEventType, handler: VoiceTrainerHandler)
  addEventListener(type: string, handler: Function) {}
}

Осталось только убедиться что все работает правильно. Для этого создадим экземпляр класса Trainer и подпишемся на события. Сразу же можно увидеть подтверждение того, что цель достигнута. У слушателя события RotateTrainerEvent параметру event указан контекстный тип RotateTrainerEvent. А слушателю события VoiceTrainerEvent параметру event указан контекстный тип VoiceTrainerEvent.

type RotateTrainerHandler = (event: RotateTrainerEvent) => void
type VoiceTrainerHandler = (event: VoiceTrainerEvent) => void

type RotateEventType = 'rotate'
type VoiceEventType = 'voice'

class RotateTrainerEvent {}
class VoiceTrainerEvent {}

class SeaLion {
  rotate(): void {}
  voice(): void {}
}

class Trainer {
  addEventListener(type: RotateEventType, handler: RotateTrainerHandler)
  addEventListener(type: VoiceEventType, handler: VoiceTrainerHandler)
  addEventListener(type: string, handler: Function) {}
}

let seaLion: SeaLion = new SeaLion()

let trainer: Trainer = new Trainer()
trainer.addEventListener('rotate', (event) => seaLion.rotate())
trainer.addEventListener('voice', (event) => seaLion.voice())

Итог

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