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

Защитники типа

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

Защитники Типа - общее

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

Простейшим примером является операция получения элемента из dom-дерева при помощи метода querySelector(), который в обычном нерекомендуемом режиме (с неактивной опцией --strictNullChecks) возвращает значение совместимое с типом Element.

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

Но в строгом рекомендуемом режиме (с активной опцией --strictNullChecks) метод querySelector() возвращает объединенный тип Element | null, поскольку искомое значение может попросту не существовать.

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

function stage_clickHandler(event: MouseEvent): void {}

Не будет лишним напомнить, что на самом деле метод querySelector возвращает тип Element | null независимо от режима. Дело в том, что в обычном режиме тип null совместим с любыми типами. То есть, в случае отсутствия элемента в dom-дереве операция присваивания значения null переменной с типом Element не приведет к возникновению ошибки.

// lib.es6.d.ts
interface NodeSelector {
  querySelector(selectors: string): Element | null;
}

Возвращаясь к примеру с получением элемента из dom-дерева стоит сказать, что если раскомментировать строчку кода в которой происходит подписка элемента на событие, то даже случае, если элемент существует, на этапе компиляции все равно возникнет ошибка. Дело в том что компилятор TypeScript не позволит вызвать метод addEventListener, поскольку для него объект, на который ссылается переменная, принадлежит к типу Element ровно настолько же, насколько он принадлежит к типу null.

const stage: Element | null = document.querySelector(
  '#stage'
);
stage.addEventListener('click', stage_clickHandler); // тип переменной stage Element или Null?

function stage_clickHandler(event: MouseEvent): void {}

Именно из-за этой особенности или другими словами, неоднозначности, которую вызывает тип Union, в TypeScript, появился механизм называемый защитниками типа (Type Guards).

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

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

Представим два класса, Bird и Fish, в обоих из которых реализован метод voice. Кроме этого, в классе Bird реализован метод fly, а в классе Fish — метод swim. Далее представим функцию с единственным параметром, принадлежащему к объединению типов Bird и Fish. В теле этой функции без труда получится выполнить операцию вызова метода voice у её параметра, так как этот метод объявлен и в типе Bird, и в типе Fish. Но при попытке вызвать метод fly или swim возникает ошибка, так как эти методы не являются общими для обоих типов. Компилятор попросту находится в подвешенном состоянии и не способен самостоятельно определится.

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

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

function move(animal: Bird | Fish): void {
  animal.voice(); // Ok

  animal.fly(); // Error
  animal.swim(); // Error
}

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

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

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

Сужение диапазона множества типов на основе типа данных

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

К помощи оператора typeof прибегают тогда, когда хотят установить принадлежность к типам number, string, boolean, object, function, symbol или undefined. Если значение принадлежит к производному от объекта типу, то установить его принадлежность к типу определяемого классом и находящегося в иерархии наследования, можно при помощи оператора instanceof.

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

// Пример для оператора typeof

type ParamType =
  | number
  | string
  | boolean
  | object
  | Function
  | symbol
  | undefined;

function identifier(param: ParamType): void {
  param; // param: number | string | boolean | object | Function | symbol | undefined

  if (typeof param === 'number') {
    param; // param: numer
  } else if (typeof param === 'string') {
    param; // param: string
  } else if (typeof param === 'boolean') {
    param; // param: boolean
  } else if (typeof param === 'object') {
    param; // param: object
  } else if (typeof param === 'function') {
    param; // param: Function
  } else if (typeof param === 'symbol') {
    param; // param:symbol
  } else if (typeof param === 'undefined') {
    param; // param: undefined
  }

  param; // param: number | string | boolean | object | Function | symbol | undefined
}
// Пример для оператора instenceof

class Animal {
  constructor(public type: string) {}
}

class Bird extends Animal {}
class Fish extends Animal {}
class Insect extends Animal {}

function f(param: Animal | Bird | Fish | Insect): void {
  param; // param: Animal | Bird | Fish | Insect

  if (param instanceof Bird) {
    param; // param: Bird
  } else if (param instanceof Fish) {
    param; // param: Fish
  } else if (param instanceof Insect) {
    param; // param: Insect
  }

  param; // param: Animal | Bird | Fish | Insect
}

Если значение принадлежит к типу Union, а выражение состоит из двух операторов, if и else, значение находящиеся в операторе else будет принадлежать к диапазону типов не участвующих в условии if.

// Пример для оператора typeof

function f0(param: number | string | boolean): void {
  param; // param: number | string | boolean

  if (
    typeof param === 'number' ||
    typeof param === 'string'
  ) {
    param; // param: number | string
  } else {
    param; // param: boolean
  }

  param; // param: number | string | boolean
}

function f1(param: number | string | boolean): void {
  param; // param: number | string | boolean

  if (typeof param === 'number') {
    param; // param: number
  } else {
    param; // param: string | boolean
  }

  param; // param: number | string | boolean
}
// Пример для оператора instanceof

class Animal {
  constructor(public type: string) {}
}

class Bird extends Animal {}
class Fish extends Animal {}
class Insect extends Animal {}
class Bug extends Insect {}

function f0(param: Bird | Fish | Insect): void {
  param; // param: Bird | Fish | Insect

  if (param instanceof Bird) {
    param; // param: Bird
  } else if (param instanceof Fish) {
    param; // param: Fish
  } else {
    param; // param: Insect
  }

  param; // param: Bird | Fish | Insect
}

function f1(
  param: Animal | Bird | Fish | Insect | Bug
): void {
  param; // param: Animal | Bird | Fish | Insect | Bug
  if (param instanceof Bird) {
    param; // param: Bird
  } else if (param instanceof Fish) {
    param; // param: Fish
  } else {
    param; // param: Animal | Insect | Bug
  }

  param; // param: Animal | Bird | Fish | Insect | Bug
}

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

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

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

// Пример для оператора typeof

function f(param: string | (() => string)): void {
  param; // param: string | (() => string)

  let value: string =
    typeof param !== 'string' ? param() : param;

  param; // param: string | (() => string)
}
// Пример для оператора instanceof

class Animal {
  constructor(public type: string = 'type') {}
}

function identifier(param: Animal | (() => Animal)): void {
  param; // param: Animal | (() => Animal)

  let value: Animal = !(param instanceof Animal)
    ? param()
    : param;

  param; // param: Animal | (() => Animal)
}

Так как оператор switch логически похож на оператор if/else, то может показаться, что механизм рассмотренный в этой главе будет применим и к нему. Но это не так. Вывод типов не умеет различать условия составленные при помощи операторов typeof и instanceof в конструкции switch.

Сужение диапазона множества типов на основе признаков присущих типу Tagged Union

Помимо определения принадлежности к единичному типу, диапазон типов составляющих тип Union можно сузить по признакам характерным для типа Tagged Union.

Условия составленные на основе идентификаторов варианта можно использовать во всех условных операторах включая switch.

// Пример для оператора if\esle

enum AnimalTypes {
  Animal = 'animal',
  Bird = 'bird',
  Fish = 'fish',
}

class Animal {
  readonly type: AnimalTypes = AnimalTypes.Animal;
}

class Bird extends Animal {
  readonly type: AnimalTypes.Bird = AnimalTypes.Bird;

  public fly(): void {}
}

class Fish extends Animal {
  readonly type: AnimalTypes.Fish = AnimalTypes.Fish;

  public swim(): void {}
}

function move(param: Bird | Fish): void {
  param; // param: Bird | Fish

  if (param.type === AnimalTypes.Bird) {
    param.fly();
  } else {
    param.swim();
  }

  param; // param: Bird | Fish
}
// Пример для тернарного оператора (?:)

function move(param: Bird | Fish): void {
  param; // param: Bird | Fish

  param.type === AnimalTypes.Bird
    ? param.fly()
    : param.swim();

  param; // param: Bird | Fish
}
// Пример для оператора switch

enum AnimalTypes {
  Animal = 'animal',
  Bird = 'bird',
  Fish = 'fish',
}

class Animal {
  readonly type: AnimalTypes = AnimalTypes.Animal;
}

class Bird extends Animal {
  readonly type: AnimalTypes.Bird = AnimalTypes.Bird;

  public fly(): void {}
}

class Fish extends Animal {
  readonly type: AnimalTypes.Fish = AnimalTypes.Fish;

  public swim(): void {}
}

function move(param: Bird | Fish): void {
  param; // param: Bird | Fish

  switch (param.type) {
    case AnimalTypes.Bird:
      param.fly(); // Ok
      break;

    case AnimalTypes.Fish:
      param.swim(); // Ok
      break;
  }

  param; // param: Bird | Fish
}

В случаях, когда множество типа Union составляют тип null и/или undefined, а также только один конкретный тип, выводу типов будет достаточно условия подтверждающего существование значения отличного от null и/или undefined. Это очень распространенный случай при активной опции --strictNullChecks. Условие, с помощью которого вывод типов сможет установить принадлежность значения к типам, отличными от null и/или undefined, может использоваться совместно с любыми условными операторами.

// Пример с оператором if\else

function f(param: number | null | undefined): void {
  param; // param: number | null | undefined

  if (param !== null && param !== undefined) {
    param; // param: number
  }

  // or

  if (param) {
    param; // Param: number
  }

  param; // param: number | null | undefined
}
// Пример с тернарным оператором (?:)

function f(param: number | null | undefined): void {
  param; // param: number | null | undefined

  var value: number = param ? param : 0;
  var value: number = param || 0;

  param; // param: number | null | undefined
}
// Пример с оператором switch

function identifier(
  param: number | null | undefined
): void {
  param; // param: number | null | undefined

  switch (param) {
    case null:
      param; // param: null
      break;

    case undefined:
      param; // param: undefined
      break;

    default: {
      param; // param: number
    }
  }

  param; // param: number | null | undefined
}

Кроме этого, при активной опции --strictNullChecks, в случаях со значением принадлежащем к объектному типу, вывод типов может заменить оператор Not-Null Not-Undefined. Для этого нужно составить условие содержащее проверку обращения к членам, в случае отсутствия которых может возникнуть ошибка.

// Пример с Not-Null Not-Undefined (с учетом активной опции --strictNullChecks)

class Ability {
  public fly(): void {}
}

class Bird {
  public ability: Ability | null = new Ability();
}

function move(animal: Bird | null | undefined): void {
  animal.ability; // Error, Object is possibly 'null' or 'undefined'
  animal!.ability; // Ok
  animal!.ability.fly(); // Error, Object is possibly 'null' or 'undefined'
  animal!.ability!.fly(); // Ok
}
// Пример с защитником типа (с учетом активной опции --strictNullChecks)

class Ability {
  public fly(): void {}
}

class Bird {
  public ability: Ability | null = new Ability();
}

function move(animal: Bird | null | undefined): void {
  if (animal && animal.ability) {
    animal.ability.fly(); // Ok
  }
}

Сужение диапазона множества типов на основе доступных членов объекта

Сужение диапазона типов также возможно на основе доступных (public) членов присущих типам составляющим диапазон (Union). Сделать это можно с помощью оператора in.

class A {
  public a: number = 10;
}
class B {
  public b: string = 'text';
}
class C extends A {}

function f0(p: A | B) {
  if ('a' in p) {
    return p.a; // p: A
  }

  return p.b; // p: B
}

function f1(p: B | C) {
  if ('a' in p) {
    return p.a; // p: C
  }

  return p.b; // p: B
}

Сужение диапазона множества типов на основе функции, определенной пользователем

Все перечисленные ранее способы работают только в том случае, если проверка происходит в месте отведенном под условие. Другими словами, с помощью перечисленных до этого момента способов, условие проверки нельзя вынести в отдельный блок кода (функцию). Это могло бы сильно ударить по семантической составляющей кода, а также нарушить принцип разработки программного обеспечения, который призван бороться с повторением кода (Don’t repeat yourself, DRY (не повторяйся)). Но, к счастью, для разработчиков, создатели TypeScript реализовали возможность определять пользовательские защитники типа.

В роли пользовательского защитника может выступать функция, функциональное выражение или метод, которые обязательно должны возвращать значения принадлежащие к типу boolean. Для того, чтобы вывод типов понял что вызываемая функция не является обычной функцией, у функции вместо типа возвращаемого значения указывают предикат (предикат — это логическое выражение, значение которого может быть либо истинным true, либо ложным false).

Выражение предиката состоит из трех частей и имеет следующий вид identifier is Type.

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

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

// Пример с функцией конструктором

function Constructor() {}

Constructor.prototype.validator = function (): this is Object {
  // Error
  return true;
};
// Пример с литералом объекта

interface IPredicat {
  validator(): this is Object; // Ok
}

var object: IPredicat = {
  // Ok
  validator(): this is Object {
    // Error
    return this;
  },
};

var object: { validator(): this is Object } = {
  // Error
  validator(): this is Object {
    // Error
    return this;
  },
};

Ко второму члену выражения относится ключевое слово is которое служит в качестве утверждения. В качестве третьего члена выражения может выступать любой тип данных.

// Пример предиката функции (function)

function isT1(p1: T1 | T2 | T3): p1 is T1 {
  return p1 instanceof T1;
}

function identifier(p1: T1 | T2 | T3): void {
  if (isT1(p1)) {
    p1; // p1: T1
  }
}
// Пример предиката функционального выражения (functional expression)

const isT2 = (p1: T1 | T2 | T3): p1 is T2 =>
  p1 instanceof T2;

function identifier(p1: T1 | T2 | T3): void {
  if (isT2(p1)) {
    p1; // p1: T2
  }
}
// Пример предиката метода класса (static method)

class Validator {
  public static isT3(p1: T1 | T2 | T3): p1 is T3 {
    return p1 instanceof T3;
  }
}

function identifier(p1: T1 | T2 | T3): void {
  if (Validator.isT3(p1)) {
    p1; // p1: T3
  }
}

Условие, на основании которого разработчик определяет принадлежность одного из параметров к конкретному типу данных, не ограничено никакими конкретными правилами. Исходя из результата выполнения условия true или false, вывод типов сможет установить принадлежность указанного параметра к указанному типу данных.

class Animal {}
class Bird extends Animal {
  public fly(): void {}
}

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

class Insect extends Animal {
  public crawl(): void {}
}

class AnimalValidator {
  public static isBird(animal: Animal): animal is Bird {
    return animal instanceof Bird;
  }

  public static isFish(animal: Animal): animal is Fish {
    return (animal as Fish).swim !== undefined;
  }

  public static isInsect(animal: Animal): animal is Insect {
    let isAnimalIsNotUndefinedValid: boolean =
      animal !== undefined;
    let isInsectValid: boolean = animal instanceof Insect;

    return isAnimalIsNotUndefinedValid && isInsectValid;
  }
}

function move(animal: Animal): void {
  if (AnimalValidator.isBird(animal)) {
    animal.fly();
  } else if (AnimalValidator.isFish(animal)) {
    animal.swim();
  } else if (AnimalValidator.isInsect(animal)) {
    animal.crawl();
  }
}

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

class Animal {
  constructor(public type: string) {}
}

class Bird extends Animal {}
class Fish extends Animal {}

function move(animal: Bird | Fish): void {
  if (animal instanceof Bird) {
    animal; // animal: Bird
  } else if (animal instanceof Fish) {
    animal; // animal: Fish
  } else {
    animal; // animal: never
  }
}