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

Вывод типов

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

Вывод типов - общие сведения

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

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

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

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

Вывод примитивных типов

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

enum Enums {
  Value,
}

let v0 = 0; // let v0: number
let v1 = 'text'; // let v1: string
let v2 = true; // let v2: boolean
let v3 = Symbol(); // let v3: symbol
let v4 = Enums.Value; // let v4: Enums

Вывод примитивных типов для констант (const) и полей только для чтения (readonly)

Когда дело доходит до присваивания значений, принадлежащих к примитивным типам, таким конструкциям, как константы (const) и неизменяемые поля (модификатор readonly), поведение вывода типов изменяется.

В случае, когда значение принадлежит к примитивным типам number, string или boolean, вывод типов указывает принадлежность к литеральным примитивным типам, определяемым самим значением.

const v0 = 0; // let v0: 0
const v1 = 'text'; // let v1: 'text'
const v2 = true; // let v2: true

class Identifier {
  readonly f0 = 0; // f0: 0
  readonly f1 = 'text'; // f1: 'text'
  readonly f2 = true; // f2: true
}

Если значение принадлежит к типу enum, то вывод типов установит принадлежность к типу enum.

enum Enums {
  Value,
}

const v = Enums.Value; // let v: Enums

class Identifier {
  readonly f = Enums.Value; // f: Enums
}

Когда вывод типов встречает значение принадлежащие к типу symbol, его поведение зависит от конструкции, которой присваивается значение. Так, если вывод типов работает с константой, то тип определяется как запрос типа (глава Type Queries (запросы типа), Alias (псевдонимы типа)) самой константы. Если вывод типов устанавливает принадлежность к типу неизменяемого поля, то тип будет определен как symbol. Происходит так потому, что вместе с созданием нового экземпляра в системе будет определяться и новый символ, что противоречит правилам установленным для Unique Symbol (глава Примитивные литеральные типы Number, String, Boolean, Unique Symbol, Enum).

const v = Symbol(); // const v: typeof v

class Identifier {
  readonly f = Symbol(); // f: symbol
}

Вывод объектных типов

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

class ClassType {}
interface InterfaceType {}

type TypeAlias = number;

let typeIntreface: InterfaceType;
let typeTypeAlias: TypeAlias;

let v0 = { a: 5, b: 'text', c: true }; // let v0: { a:number, b:string, c: boolean }
const v1 = { a: 5, b: 'text', c: true }; // let v1: { a:number, b:string, c: boolean }

let v3 = new ClassType(); // let v3: ClassType
let v4 = typeIntreface; // let v4: InterfaceType
let v5 = typeTypeAlias; // let v5: number

Вывод типа для полей класса на основе инициализации их в конструкторе

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

class Square {
  /**
   * Поля без явной аннотации типа.
   * Вывод типов определяет их
   * принадлежность к типу any.
   *
   * (property) Square.area: any
   * (property) Square.sideLength: any
   *
   * От этого возникает ошибка ->
   * Member 'area' implicitly has an 'any' type.(7008)
   * Member 'sideLength' implicitly has an 'any' type.(7008)
   */
  area;
  sideLength;

  constructor() {}
}

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

class Square {
  /**
   * Поля без явной аннотации типа,
   * но ошибки не возникает, поскольку
   * вывод типов определяет их принадлежность
   * к типу number, так как поле sideLength
   * инициализиируется в конструкторе его параметром
   * принадлежащим к типу number, а поле area инициализируется
   * там же с помощью выражения результат которого
   * также принадлежит к типу number.
   *
   * (property) Square.area: number
   * (property) Square.sideLength: number
   */
  area;
  sideLength;

  constructor(sideLength: number) {
    this.sideLength = sideLength;
    this.area = this.sideLength ** 2;
  }
}

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

class Square {
  /**
   * Error ->
   * Member 'area' implicitly has an 'any' type.
   *
   */
  area;
  sideLength;

  constructor(sideLength: number) {
    this.sideLength = sideLength;
    this.init();
  }

  init() {
    this.area = this.sideLength ** 2;
  }
}

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

class Square {
  /**
   * [1] ...вывод типов определяет принадлежность
   * поля sideLength как ->
   *
   * (property) Square.sideLength: number | undefined
   */
  sideLength;

  constructor(sideLength: number) {
    /**
     * [0] Поскольку инициализация зависи от
     * условия выражения которое выполнится
     * только во время выполнения программы...
     */
    if (Math.random()) {
      this.sideLength = sideLength;
    }
  }

  get area() {
    /**
     * [2] Тем не менее возникает ошибка
     * поскольку операция возведения в степень
     * производится над значение которое может
     * быть undefined
     *
     * Error ->
     * Object is possibly 'undefined'.
     */
    return this.sideLength ** 2;
  }
}

Вывод объединенных (Union) типов

С выводом типов объединения (глава Union, Intersection) связаны как очевидные, так и нет, случаи.

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

let v = [0, 'text', true]; // let v: (string | number | boolean)[]

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

let v = [0, 'text', true]; // let v: (string | number | boolean)[]

let item = v[0]; // let item: string | number | boolean

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

let v = [
  { a: 5, b: 'text' },
  { a: 6, b: 'text' },
]; // let v: { a: number, b: string }[]

В примере, вывод типов выводит ожидаемый и предсказуемый результат для массива объектов, чьи типы полностью идентичны. Идентичны они по той причине, что вывод типов установит тип { a: number, b:string } для всех элементов массива.

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

let v = [{ a: 5, b: 'text' }, { a: 6 }, { a: 7, b: true }]; // let v: ({ a: number, b: string } | { a: number, b?: undefined } | { a: number, b: boolean })[]

Как видно из примера выше, вывод типов приводит все объектные типы, составляющие тип объединение, к единому виду. Он добавляет к типам несуществующие в них, но существующие в других объектных типах, поля, декларируя их как необязательные (глава Optional, Not-Null, Not-Undefined, Definite Assignment Assertion). Сделано это для возможности конкретизировать тип любого элемента массива. Простыми словами, чтобы не получить ошибку во время выполнения, любой элемент массива должен иметь общие для всех элементов признаки. Но так как в реальности, в объектах, некоторые члены вовсе могут отсутствовать, вывод типов, чтобы повысить типобезопасность, декларирует их как необязательные. Таким образом он предупреждает разработчика о возможности возникновения ситуации, при которой эти члены будут иметь значение undefined, что и демонстрируется в примере ниже.

let v = [{ a: 5, b: 'text' }, { a: 6 }, { a: 7, b: true }]; // let v: ({ a: number, b: string } | { a: number, b?: undefined } | { a: number, b: boolean })[]

let a = v[0].a; // let a: number
let b = v[0].b; // let b: string | boolean | undefined

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

class A {
  public a: number = 0;
}

class B {
  public a: string = '';
  public b: number = 5;
}

let v = [new A(), new B()]; // let v: (A | B)[]

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

class A {}
class B extends A {
  f0 = 0;
}
class C extends A {
  f1 = '';
}
class D extends A {
  f2 = true;
}
class E extends D {
  f3 = {};
}

let v3 = [new A(), new B(), new C(), new D(), new E()]; // let v3: A[]
let v4 = [new B(), new C(), new D(), new E()]; // let v4: (B | C | D)[]

Те же самые правила применяются при выводе типа значения возвращаемого тернарным оператором.

class A {}
class B extends A {
  f0 = 0;
}
class C extends A {
  f1 = '';
}
class D extends A {
  f2 = true;
}
class E extends D {
  f3 = {};
}

let v0 = false ? new A() : new B(); // let v0: A
let v1 = false ? new B() : new C(); // let v1: B | C
let v2 = false ? new C() : new D(); // let v2: C | D

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

Вывод пересечения (Intersection) с дискриминантными полями

Если при определении типа пересечения, определяющее его множество включает больше одного типа определяющего одноименные дискриминантные поля принадлежащие к разным типам, то такое пересечение определяется как тип never. Данная тема подробно бала рассмотрены в главе Discriminated Union.

type A = {
  type: 'a'; // дискриминантное поле

  a: number;
};
type B = {
  type: 'b'; // дискриминантное поле

  b: number;
};
type C = {
  c: number;
};
type D = {
  d: number;
};

/**
 * Как видно типы A и B
 * определяют одноименные
 * дискриминантные поля type
 * принадлежащих к разным типам a и b
 * поэтому тип T будет определен
 * как тип never.
 *
 * type T = never
 */

type T = A & B & C & D;

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

type A = {
  type: 'a'; // дискриминантное поле с типом a

  a: number;
};
type B = {
  type: 'a'; // дискриминантное поле с типом b

  b: number;
};
type C = {
  c: number;
};
type D = {
  d: number;
};

/**
 * Как видно типы A и B
 * по-прежднему определяют одноименные
 * дискриминантные поля type, но на этот
 * раз они принадлежат к одному типу a,
 * поэтому тип T будет определен ожидаемым
 * образом.
 *
 *
 *
 * type T = A & B & C & D
 */

type T = A & B & C & D;

Вывод типов кортеж (Tuple)

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

function f(
  ...rest: [number, string?, boolean?]
): [number, string?, boolean?] {
  return rest;
}

let l = f(5).length; // let l: 1 | 2 | 3

Кроме того, остаточные параметры (...rest), аннотированные с помощью параметра типа, рассматриваются и представляются выводом типа как принадлежащие к типу-кортежу.

function f<T extends any[]>(...rest: T): T {
  return rest;
}

// рассматриваются

f(5); // function f<[number]>(rest_0: number): void
f(5, ''); // function f<[number, string]>(rest_0: number, rest_1: string): void
f(5, '', true); // function f<[number, string, boolean]>(rest_0: number, rest_1: string, rest_2: boolean): void

// представляются

let v0 = f(5); // let v0: [number]
let v1 = f(5, ''); // let v1: [number, string]
let v2 = f(5, '', true); // let v2: [number, string, boolean]

Если функция, определяющая остаточные параметры принадлежащие к параметру типа будет вызвана с аргументами включающих массив указанный при помощи расширяющего синтаксиса (spread syntax), то тип для него будет выведен в виде остаточного типа ...rest.

function tuple<T extends any[]>(...args: T): T {
  return args;
}

let numberAll: number[] = [0, 1, 2];
let v0 = tuple(5, '', true); // let v0: [number, string, boolean]
let v1 = tuple(5, ...numberAll); // let v1: [number, ...number[]]

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

function tuple<T extends any[]>(...args: T): T {
  return args;
}

let numberAll: number[] = [0, 1, 2];
let v0 = tuple(5, ...numberAll); // let v0: [number, ...number[]]
let v1 = tuple(5, ...numberAll, ''); // let v1: [number, ...(string | number)[]]