Совместимость функциональных типов (Compatible Function Types)

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

Важно

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

Совместимость параметров

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

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

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

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

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

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

ts
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

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

ts
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

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

ts
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

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

ts
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 отсутствуют, но не наоборот. Если ещё более просто, то больший тип совместим с меньшим типом данных. В случае с параметрами функциональных типов, все с точностью наоборот.

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

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

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

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

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

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

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

ts
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

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

ts
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 через операцию приведения типов.

ts
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 практиками. Подробно бивариантность была рассмотрена в главе “Экскурс в типизацию - Совместимость типов на основе вариантности”.

ts
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, сопоставление будет происходить по контрвариантным правилам (глава “Экскурс в типизацию - Совместимость типов на основе вариантности”).

ts
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

Совместимость возвращаемого значения

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

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

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

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

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

ts
class Animal { name: string; } let animals: Animal[] = [ new Animal(), new Animal() ];

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

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

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

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

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

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

ts
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

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

ts
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