Если у вас есть опыт работы с любым типизированным языком, то, вероятно, вам знакома концепция перегрузки функций. Если нет, то вкратце напомню ее суть:
“В некоторых языках программирования перегрузка функций или метод перегрузки — это возможность создавать несколько одноименных функций с разными реализациями. При вызовах перегруженной функции будет выполняться конкретная реализация этой функции в соответствии с контекстом вызова, позволяя одному вызову функции выполнять разные задачи в зависимости от контекста.”
Функциональность что надо! А можно ли ее использовать в 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 способствуют очистке кода. При правильном их использовании код становится более читаемым и удобным для обслуживания. Они сделают ваши контракты более полноценными и точными.
Читайте также:
- Комбинаторы парсеров: от parsimmon до nom (Typescript → Rust)
- JavaScript превращается в TypeScript?
- 3 альтернативы инструкции Switch в Typescript
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Jose Granja: Mastering Function Overloading in TypeScript