Перегрузка функций в TypeScript

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

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

Функциональность что надо! А можно ли ее использовать в JavaScript, учитывая, что это не типизированный язык. И какие у нее ограничения? 

А как насчет TypeScript? Поддерживает ли он перегрузку по умолчанию? Есть ли какие-нибудь недостатки?

В статье мы ответим на все поставленные вопросы. А когда разберем основы  —  рассмотрим наилучшие практики. 

Перегрузка в JavaScript 

Допускает ли Javascript перегрузку функций? Строго говоря, нет. Мы можем выполнить что-то похожее, но у такого подхода есть ряд недостатков. А какие  —  узнаем, рассмотрев несколько примеров. 

Допустим, нужно создать функцию concatString, принимающую от 1–3 строковых параметров:

function concatString(s1, s2, s3) {
  let s = s1;
  if(s2) {
    s += `, ${s2}`;
  }
  if(s3) {
    s += `, ${s3}`;
  }
  return s;
}

concatString('one');
concatString('one','two');
concatString('one', 'two', 'three');

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

concatString('one', true);

Метод выполнится, но вернет неправильный результат. Это легко исправить. Проверим, чтобы аргументы typeof принимали только строки:

if(s2 && typeof s2 === 'string')

Таким образом, проблема с разными типами “решена”. А что если вызвать метод с разным числом аргументов? Вот одно быстрое решение. Мы можем проверить свойство length у arguments и вернуть ошибку в случае его превышения. В следующем примере оно будет равняться 3.

function concatString(s1, s2, s3) {
  if (arguments.length > 3) {
    throw new Error('signature not supported');
  }
  let s = s1;
  if(s2 && typeof s2 === 'string') {
    s += `, ${s2}`;
  }
  if(s3 && typeof s3 === 'string') {
    s += `, ${s3}`;
  }
  return s;
}

Глядя на этот пример, нетрудно понять, почему сообщество Javascript предпочитает игнорировать перегрузку. Методы разрастаются, их становится все сложнее читать и поддерживать. А ошибки обнаруживаются только во время выполнения. 

TypeScript спешит на помощь 

TypeScript говорит “стоп” этим заморочкам с динамическим программированием. С типизированными методами под рукой становится ясно, что выражает и возвращает функция. Поддерживает ли он перегрузку по умолчанию? 

Да! Но слегка в отличном от привычного нам вида. TypeScript сможет помочь только на этапе редактирования и сборки, поскольку код в итоге будет преобразован в JavaScript. Во время выполнения у нас будут те же самые инструменты, что и в JavaScript. Хоть это и не идеальный вариант, но для приведения в порядок файлов типизаций вполне подойдет. 

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

function concatString(s1: string, s2?: string, s3?: string) {
  let s = s1;
  if(s2) {
    s += `, ${s2}`;
  }
  if(s3) {
    s += `, ${s3}`;
  }
  return s;
}

// ❎  теперь это сработает 
concatString('one');
concatString('one','two');
concatString('one', 'two', 'three');

// ❌ мы получим ошибки компиляции, если попытаемся сделать 
concatString('one', true);
concatString('one', 'two', 'three', 'four');

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

Усложненный сценарий

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

Рассмотрим пример: 

function helloWorld(s?: string) {
  if (!s) {
    return Math.random();
  }
  return s;
}

// x имеет тип string | number 
const x = helloWorld('test');

В представленном выше методе возвращаемым типом будет string | number. TypeScript выведет возможные типы возвращаемого значения и объединит их с помощью специального синтаксиса.

По методу видно, что если аргумент  —  string, то он возвращает string, в противном случае  —  number. А что если выразить это с помощью более конкретных типов? Можно применить синтаксис перегрузки для сопоставления типа аргумента с возвращаемым типом. 

Посмотрим на результат: 

function helloWorld(): number;
function helloWorld(s: string): string;
function helloWorld(s?: string) {
  if (!s) {
    return Math.random();
  }
  return s;
}

// ❎ x имеет тип string 
const x = helloWorld('test');
// ❎ y имеет тип number 
const y = helloWorld();

Теперь при передаче функции string обратно получим тип string. При отсутствии аргументов вернется тип number

Как работает синтаксис? 

  • function helloWorld(): number: первая перегрузка. 
  • function helloWorld(s: string): string: вторая перегрузка.
  • function helloWorld(s?: string): Основная функция, которая должна принять все возможные перегрузки, объявленные ранее. Она должна соответствовать всем объявленным выше типам возвращаемых значений. В данном случае выводится возвращаемый тип string | number

Что произойдет при условии применения несовместимых типов возвращаемых значений? TypeScript не будет компилироваться и выдаст ошибку. 

function helloWorld(): Date;
//       ^^^^^^^^^^^
// ❌ Ошибка: Данная сигнатура перегрузки не совместима с 
// сигнатурой ее реализации 
function helloWorld(s: string): string;
function helloWorld(s?: string) {
  if (!s) {
    return Math.random();
  }

  return s;
}

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

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

Убедимся в этом на примере, изменив порядок объявления метода перегрузки. Будьте готовы к массе ошибок. 

function helloWorld(): number;
//       ^^^^^^^^^^^^
// ❌ Ошибка: Данная сигнатура перегрузки не совместима 
// с сигнатурой ее реализации 
function helloWorld(s?: string);
//       ^^^^^^^^^^^^
// ❌ Ошибка: Данная сигнатура перегрузки не совместима
// с сигнатурой ее реализации

function helloWorld(s: string): string {
  if (!s) {
    return Math.random();
    //     ^^^^^^^^^^^^^^
    // ❌ Тип 'number' нельзя присвоить типу 'string' 
  }

  return s;
}

Исходя из документации, порядок указания перегрузок предполагает переход от более ограничивающих к менее ограничивающим. Предыдущий основной метод function helloWorld(s: string): string из разряда последних, поэтому находится он должен в конце. 

Объединение нескольких типов параметров 

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

function foo(arg1: number, arg2: number): number;
function foo(arg1: string, arg2: string): string;
function foo(arg1: string | number, arg2: string | number) {
  return arg1 || arg2;
}

// ❎ x имеет тип string 
const x = foo('sample1', 'sample2');
// ❎ y имеет тип number 
const y = foo(10, 24);

// ❌ Ошибка: Ни одна перегрузка не соответствует этому вызову 
const a = foo(10, 'sample3');
// ❌ Ошибка: Ни одна перегрузка не соответствует этому вызову 
const b = foo('sample3', 10);

Обратите внимание, что arg1 и arg2 должны быть типом string | number, чтобы соответствовать возможным типам, указанным в предыдущих перегрузках. Как только освоите суть, все станет проще простого. 

⚠️ Есть один небольшой нюанс, который следует иметь в виду. TypeScript не сможет вывести типы в теле функции на основе параметров. На данный момент основной метод будет независим от своих объявлений перегрузок. Это значит, что даже если вы отметите в теле функции, что arg1 —  строка, TypeScript не определит, что arg2 тоже должен быть строкой. 

Рекомендации по работе с перегрузкой функций 

Итак, с концепцией перегрузки функций в TypeScript все понятно, рассмотрим наиболее распространенные рекомендации по работе с ней: 

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

// ❌ вместо этого 
interface Example {
  foo(one: number): number;
  foo(one: number, two: number): number;
  foo(one: number, two: number, three: number): number;
}

// ❎ делаем так 
interface Example {
  foo(one?: number, two?: number, three?: number): number;
}

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

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

// ❌ вместо этого 
interface Example {
  foo(one: number): number;
  foo(one: number | string): number;
}

// ❎ делаем так
interface Example {
  foo(one: number | string): number;
}

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

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

Выводы 

Перегрузка функций  —  важная и полезная функциональность в TypeScript. Однако злоупотреблять ей не стоит. Как мы уже видели, иногда можно обойтись одним модификатором optional.

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

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Jose Granja: Mastering Function Overloading in TypeScript

Предыдущая статьяКакой язык программирования учить в 2022 году?
Следующая статьяОсновы безопасного программирования