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

Совместимость функций

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

Для того чтобы понять замысел создателей TypeScript, нужно детально разобрать каждый момент, но прежде стоит уточнить одну деталь. В примерах, которые будут обсуждаться в главе, посвященной типизации функциональных типов, будет использоваться уточняющий шаблон : Target = Source.

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

Типизация (Function Types) - совместимость параметров

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

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

type T1 = (p1: number, p2: string) => void

let v1: T1 = (p3: number, p4: string) => {} // Ok -> разные идентификаторы
let v2: T1 = (p1: number, p2: boolean) => {} // Error

При этом стоит заметить, что идентификаторы параметров не участвуют в проверке на совместимость.

type T1 = (...rest: number[]) => void

let v1: T1 = (...numbers: number[]) => {} // Ok -> разные идентификаторы

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

type T1 = (p1: number, p2?: string) => void

let v1: T1 = (p1: number) => {} // Ok
let v2: T1 = (p1: number, p2: string) => {} // Ok или Error с включенным флагом --strictNullChecks
let v3: T1 = (p1: number, p2: boolean) => {} // Error
let v4: T1 = (p1: number, p2?: boolean) => {} // Error

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

type T1 = (...rest: any[]) => void
type T2 = (p0: number, p1: string) => void

let v0: T1 = (...rest) => {}
let v1: T2 = (p0, p1) => {}

let v2: T1 = v1 // Ok
let v3: T2 = v0 // Ok

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

type T0 = (p0: number, ...rest: any[]) => void
type T1 = (p0: number, p1: string) => void
type T2 = (p0: string, p1: string) => void

let v0: T0 = (p0, ...rest) => {}
let v1: T1 = (p0, p1) => {}
let v2: T2 = (p0, p1) => {}

let v3: T0 = v1 // Ok
let v4: T1 = v0 // Ok
let v5: T2 = v0 // Error
let v6: T0 = v2 // Error

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

type T0 = (p0: number, p1: string) => void
type T1 = () => void

let v0: T0 = () => {} // Ok
let v1: T0 = (p: number) => {} // Ok
let v3: T1 = (p?: number) => {} // Ok -> необязательный параметр
let v4: T1 = (p: number) => {} // Error -> обязательных параметров больше чем в типе T1

На данный момент уже известно, что два объектных типа (:T = S), на основании структурной типизации, считаются совместимыми, если в типе S присутствуют все признаки типа T. Помимо этого, тип S может быть более специфическим, чем тип T. Простыми словами, тип S, помимо всех признаков, присущих в типе T, также может иметь признаки, которые в типе T отсутствуют, но не наоборот. Простыми словами, больший тип совместим с меньшим типом данных. В случае с параметрами функциональных типов, все с точностью наоборот.

type T = (p0: number) => void

let v0: T = (p0) => {} // Ok, такое же количество параметров
let v1: T = () => {} // Ok, параметров меньше
let v2: T = (p0, p1) => {} // Error, параметров больше

Такое поведение проще всего объяснить на примере работы с методами массива. За основу будет взята декларация метода forEach из библиотеки lib.es5.d.ts.

forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void;

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

callbackfn: (value: T, index: number, array: T[]) => void;

Если бы функциональный тип с большим числом параметров не был совместим с функциональным типом с меньшим числом параметров, то при работе с методом forEach, при необходимости только в одном, первом параметре, обязательно бы приходилось создавать callback со всеми тремя параметрами, что привело бы к излишнему коду.

class Animal {
  name: string
}
class Elephant extends Animal {}
class Lion extends Animal {}

let animals: Animal[] = [new Elephant(), new Lion()]

let animalNames: string[] = []

animals.forEach((value, index, source) => {
  // Плохо
  animalNames.push(value.name)
})

animals.forEach((value) => {
  // Хорошо
  animalNames.push(value.name)
})

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

function f0<T>(p0: T): void {}
function f1<T, S>(p0: T, p1: S): void {}

type T0 = typeof f0
type T1 = typeof f1

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

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

function f0<T>(p: T): void {}
function f1(p: number): void {}

type T0 = typeof f0
type T1 = typeof f1

let v0: T0 = f1 // Error, параметр типа T не совместим с параметром типа number
let v1: T1 = f0 // Ok, параметр типа number совместим с параметром типа T

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

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

class T0 {
  f0: number
}
class T1 {
  f0: number
  f1: string
}
let v0: T0 = new T1() // Ok -> неявное преобразование типов

let v1: T1 = new T0() // Error
let v2: T1 = new T0() as T1 // Ok -> явное приведение типов

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

class T0 {
  f0: number
}
class T1 {
  f0: number
  f1: string
}

function f0(p: T1): void {}
function f1(p: T0): void {}

type FT0 = typeof f0
type FT1 = typeof f1

// бивариантное поведение
let v0: FT0 = f1 // Ok, параметр с типом T1 совместим с параметром принадлежащим к типу T0. Кроме того, тип T1 совместим с типом T0.
let v1: FT1 = f0 // Ok, параметр с типом T0 совместим с параметром принадлежащем к типу T1. Но тип T0 не совместим с типом T1  без явного приведения.

Изменить поведение бивариантного сопоставления параметров можно с помощью опции компилятора --strictFunctionTypes. Установив флаг --strictFunctionTypes в true, сопоставление будет происходить по контрвариантным правилам (глава Совместимость типов на основе вариантности).

class T0 {
  f0: number
}
class T1 {
  f0: number
  f1: string
}

function f0(p: T1): void {}
function f1(p: T0): void {}

type FT0 = typeof f0
type FT1 = typeof f1

// контрвариантное поведение
let v0: FT0 = f1 // Ok
let v1: FT1 = f0 // Error

Типизация (Function Types) - совместимость возвращаемого значения

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

class T0 {
  f0: number
}
class T1 {
  f0: number
  f1: string
}

type FT0 = () => T0
type FT1 = () => T1

let v0: FT0 = () => new T1() // Ok
let v1: FT1 = () => new T0() // Error

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

let v0: void = null // Ok and Error с включенным флагом strictNullChecks
let v1: void = undefined // Ok

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

type T = () => void

let v0: T = () => 0 // Ok
let v1: T = () => '' // Ok
let v2: T = () => true // Ok
let v3: T = () => ({}) // Ok

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

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

class Animal {
  name: string
}

let animals: Animal[] = [new Animal(), new Animal()]

Задача заключается в получении имен объектов из первого массива с последующим сохранением их во второй массив.

Для этого создадим callback-стрелочную функцию. Слева от стрелки будет расположен один параметр value, а справа — операция сохранения имени во второй массив с помощью метода push.

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

forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void;

Но в нашем случае, в теле функции обратного вызова происходит операция добавления элемента в массив. Результатом этой операции является значение длины массива. То есть, метод push возвращает значение, принадлежащий к типу number, которое в свою очередь возвращается из стрелочной функции обратного вызова, переданного в метод forEach, у которого этот параметр задекларирован как функция возвращающая тип void, что противоречит возвращенному типу number. В данном случае отсутствие ошибки объясняется совместимостью типа void, используемого в функциональных типах, со всеми остальными типами.

class Animal {
  name: string
}

let animals: Animal[] = [new Animal(), new Animal()]

let animalNameAll: string[] = []

animalNameAll.forEach((animal) => animalNameAll.push(animal.name)) // forEach ожидает () => void, а получает () => number, так как стрелочная функция без тела неявно возвращает значение, возвращаемое методом push.

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

function f0<T>(p: T): T {
  return p
}
function f1<S>(p: S): S {
  return p
}

type T0 = typeof f0
type T1 = typeof f1

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

Кроме того, параметр типа совместим с любым конкретным типом данных, но не наоборот.

function f0<T>(p: T): T {
  return p
}
function f1(p: number): number {
  return p
}

type T0 = typeof f0
type T1 = typeof f1

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