Типизация в TypeScript

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

Общие сведения

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

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

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

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

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

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

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

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

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

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

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

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

ts
const value = 5 + []; // Error

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

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

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

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

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

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

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

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

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

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

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

ts
class Bird { name; wings; }
class Fish { name; }

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

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

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

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

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

ts
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

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

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

ts
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 — статически типизированный язык, и поэтому он пытается всему указать типы. В случаях, когда тип не был указан явно, в работу включается вывод типов. Предположим, что существует массив ссылка на который присваивается переменной объявленной без явного указания типа. Для того, что бы вывод типов смог вывести тип для переменной, ему нужно проанализировать данные хранящиеся в массиве (если они хранятся).

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

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

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

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

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

ts
class Animal {}
class Elephant extends Animal { trunk; }
class Lion extends Animal {}

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

В случае, если в массиве не будет присутствовать базовый для всех типов тип Animal, то вывод типов будет расценивать массив как принадлежащий к типу объединение Elephant | Lion.

ts
class Animal {}
class Elephant extends Animal { trunk; }
class Lion extends Animal {}

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

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

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

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

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

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

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

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

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

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

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

ts
class RotateTrainerEvent {}
class VoiceTrainerEvent {}

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

ts
type RotateEventType = "rotate";
type VoiceEventType = "voice";

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

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

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

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

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

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