Если вы открыли эту статью, значит, как минимум, слышали о перегрузках в TypeScript, возможно даже пытались их применять, но быстро разочаровались и забросили. Может показаться, что это какая-то переисложненная избыточная магия.
Однако, на самом деле вы сталкиваетесь с перегрузками каждый день часто даже не осознавая этого. Вызываете ли вы addEventListener в чистом TS, defineProps во Vue, useState в React, inject в Angular или $state в Svelte — все эти привычные инструменты построены на перегрузках. Без них сохранить гибкость и строгую типизацию в рамках одной функции было бы просто невозможно.
В этой статьи я постараюсь развеять скепсис вокруг этой темы, покажу какие неочевидные преимущества дает перегрузка, даже сейчас в эпоху ИИ. Затем, на практике покажу как начать использовать перегрузки за три простых шага сведя все сложности к минимуму.
Почему так сложно
Прежде чем начать, вспомним, почему разработчики так старательно игнорируют этот, на первый взгляд, удобный механизм.
Дело в том, что в отличие от действительно строго типизированных языков, в TS перегрузка существует только на уровне типов. Компилятор перекладывает на разработчика всю грязную работу: нужно не только описать варианты вызова функции, но и самостоятельно написать исполняемый код, который вычислит типы аргументов и какое поведение следует вызвать именно для этого набора аргументов.
Такой код невалиден в TypeScript:
function whatever(v: string): string { // ...}function whatever(v: number): number { // ...}
Для компилятора это две разные функции, которые почему-то претендуют на одно имя.
Поэтому, разработчик вынужден перечислить все возможные сигнатуры отдельно, а потом еще и в рантайме объяснять как именно обработать каждый из этих сценариев:
function whatever(v: string): string;function whatever(v: number): number;function whatever(v: string | number): string | number { if (typeof v === 'number') { return v; } if (typeof v === 'string') { return v; } return v;}
Именно на этом моменте все закатывают глаза и представляют сколько вещей может пойти не так. Не стану защищать TS — это действительно не выглядит чем-то удобным.
Однако, эти сложности можно свести к минимуму полностью сохранив все преимущества, о чем мы поговорим в практической части этой статьи. Но перед этим, чтобы понять для чего все это делать, разберемся с преимуществами перегрузок.
От ненависти к любви не один шаг
Официальная документация сухо рассказывает о синтаксисе, но никак не объясняет зачем все это нужно будто это что-то очевидное.
В этой главе на простых примерах, я подробно покажу, чего лишают себя разработчики отказываясь от перегрузок.
Отдельная документация
Если вы работаете с единственной функцией с множеством сценариев использования, то вынуждены ограничиться только одним описанием в TSDoc, что потенциально приведет к огромному тексту и чем он больше, тем меньше вероятность что его прочитают, а если прочитают, то правильно поймут.
Функция с перегрузкой может иметь отдельное описание для каждого сценария использования.
Возьмем обычную функцию, добавим описание с примерами использования:
/** * Сложная функция, которая принимает строку или число и возвращает строку или число. * * @example * // Делай так и получишь строку * whatever('one'); * * // Или делай так и получишь число * whatever(1); */function whatever(v: string | number): string | number { // ...}
Видно, что описание содержит информацию о всех сценариях использования сразу, что в реальных условиях может превратиться в достаточно большой и запутанный текст.
Добавим перегрузку и посмотрим как поменялось описание:
/** * Простая функция, которая принимает строку и возвращает строку. * * @example * whatever('one'); */function whatever(v: string): string;/** * Простая функция, которая принимает число и возвращает число. * * @example * whatever(1); */function whatever(v: number): number;function whatever(v: string | number): string | number { // ...}
Обратите внимание, что описание и примеры теперь сосредоточены на разных сценариях использования и поэтому проще передают смысл. Такое описание будут видеть разработчик наведя курсор в IDE — короткую подсказку актуальную для сценария, который он хочет использовать в данный момент.
Повторное использование
В разработке часто возникает необходимость проводить манипуляции с одинаковыми или очень похожими сущностями, но немного разными способами.
Давайте разберем пример:
function findUserById(id: number): User { // ...}function findUserByName(name: string): User { // ...}
Смысл очень похож: в результате мы получим пользователя и скорей всего даже из одного и того же источника данных, но, тем не менее, они живут отдельной жизнью.
Позже, стараясь использовать готовые функции, их даже могут объединить в одном месте:
const term: number | string = 42;if (typeof term === 'number') { return findUserById(term);}if (typeof term === 'string') { return findUserByName(term);}
Обратите внимание, что здесь разработчик был вынужден написать логику принятия решения какой из двух вариантов выбрать и хорошо, что он потрудился найти все варианты использования в проекте, а не написал недостающий способ заново.
Это задача, которую помогает решить перегрузка — описать несколько вариантов вызова одной функции и собрать их обработку в одном месте.
Теперь, перепишем функцию, но с использованием перегрузки:
function findUser(id: number): User;function findUser(name: string): User;function findUser(term: number | string): User { if (typeof term === 'number') { // ... } if (typeof term === 'string') { // ... } throw new Error('Тип аргумента не поддерживается');}
Таким образом, вся логика выбора сценария использования собрана в одном месте, а значит может быть легко переиспользована. К тому же, теперь есть единственная точка расширения и если завтра появится новый способ поиска пользователя, мы знаем где нужно добавить новую реализацию и новые тесты.
Управление возвращаемым типом
Когда мы работаем с типом аргумента функции, этот тип может сужаться до нужного на лету, но с возвращаемым типом так не происходит, так как никакой связи между ним и аргументами просто нет. В результате, каждый раз при попытке обращения к возвращаемому значению, разработчику придется сужать тип вручную перед тем как что-то сделать.
Представим, что мы хотим создать универсальную функцию для поиска одного и нескольких пользователей:
function findUser(ids: number | number[]): User | User[] { // ...}
Но при попытке обращения к результату будет ошибка:
const user = findUser(1); // user: User | User[]console.log(user.name);console.log(user.length);
И это нормально: TS должен быть уверен, что операция завершится успешно, но если ключ может отсутствовать, то компилятор об этом предупредит.
При таком подходе разработчики будут вынуждены уточнять тип вручную разными способами:
const user = fineUser(1); // user: User | User[]const users = fineUser([1, 2]); // users: User | User[]if ('name' in user) { console.log(user.name);}if (Array.isArray(users)) { console.log(users.length);}
На практике это может привести к тому, что проект погрязнет в комбинациях подобных проверок. И хорошо, если проверки вообще будут выполнять: всегда есть соблазн просто отключить их при помощи оператора as.
Перегрузка позволяет возвращаемому типу сужаться автоматически в зависимости от аргументов.
Давайте посмотрим как все поменяется с перегрузкой:
function findUser(id: number): User;function findUser(ids: number[]): User[];function findUser(ids: number | number[]): User | User[] { // ...}const user = findUser(1); // user: Userconst users = findUser([1, 2]); // users: User[]console.log(user.name);console.log(users.length);
Мы получили ожидаемый тип без каких-либо дополнительных конструкций: все они спрятаны внутри.
Обратная совместимость
Есть функции, которые живут в проекте годами. За это время меняются требования, появляются и исчезают сценарии использования, и рано или поздно возникает необходимость поменять API функции.
Но есть проблема — функция уже используется в огромном количестве мест на проекте, а если это библиотека, то и за его пределами. В таким условиях нельзя безопасно поменять набор аргументов и возвращаемых значений, проект просто не скомпилируется.
Представим простую функцию, которая изначально принимала один аргумент:
function whatever(a: string): void { // ...}whatever('a');
Через какое-то время появляется новый сценарий, для которого этой функции нужен ещё один аргумент:
function whatever(a: string, b: string): void { // ...}whatever('a', 'b');
Новый вариант прекрасно работает. Но как теперь быть с существующими вызовами о части которых мы даже не можем узнать?
Посмотрим, как такую задачу можно решить с помощью перегрузки:
// Старый сценарийfunction whatever(a: string): void; // Новый сценарийfunction whatever(a: string, b: string): void; // Реализация адаптируется ко всем сценариямfunction whatever(a: string, b?: string): void { // ...}// Новые сценарии работают как ожидается whatever('a', 'b');// Старый сценарий продолжает работатьwhatever('a');
Мы просто описали старый сценарий и добавили новый, сразу увидели, что компилятор ругается на то что реализация обязана соответствовать им всем и тут же внесли соответствующие изменения в исполняемую функцию.
Позже становится понятно, что мы уже не пользуемся старым сценарием и не хотим его больше поддерживать.
Пометим его как устаревший явно:
/** * @deprecated Эта перегрузка устарела. * Используйте {@link whatever | whatever(a: string)}. */function whatever(a: number): void;
В результате IDE будет отображать эту функцию зачеркнутой при использовании, но объяснит какой именно сценарий следует теперь использовать. Так можно обеспечить плавную миграцию для потребителей.
Практика
Может показаться, что этот механизм требует титанических усилий и в нем легко запутаться. На самом деле всё гораздо проще. Главное — не пытаться решать все задачи разом, а разбить процесс на несколько простых этапов:
Проектирование кейсов
Определить, какие варианты использования должна поддерживать функция.
Объединение кейсов
Описать общую сигнатуру исполняемой функции, которая сможет принять аргументы всех предусмотренных типов.
Реализация кейсов
Вычислить типы аргументов и написать исполняемый код для каждого кейса.
Давайте пройдем каждый этап на простом примере и убедимся, что писать перегрузки — это совсем не страшно.
Проектирование кейсов
Представим, что мы хотим создать функцию, которая умеет складывать разные типы данных: например, числа, строки, а также числа и строки одновременно.
Спроектируем каждый кейс:
function concatenate(a: string, b: string): string;function concatenate(a: number, b: number): number;function concatenate(a: string, b: number): string;
На данный момент не нужно задумываться над исполняемым кодом, поэтому чтобы этот назойливый компилятор нас не беспокоил раньше времени, просто дадим ему такую заглушку:
function concatenate(...args: any[]): any { throw new Error('Заглушка');}
Объединение кейсов
Мы определились со сценариями, теперь нужно написать настоящую функцию, типы которой будут совместимы с каждым из сценариев.
Для этого нужно взять каждый аргумент по порядку, посмотреть какие типы могут быть переданы в этот аргумент и просто объединить их в union, затем проделать тоже самое с возвращаемым типом.
Смотрим на первый, пройдясь по всем кейсам видим, что туда может попасть строка или число, объединим типы:
function concatenate(a: string | number): any { throw new Error('Заглушка');}
Компилятор ожидаемо подсказывает, что мы покрыли ещё не все кейсы. Продолжим в том же духе со вторым аргументом:
function concatenate(a: string | number, b: string | number): any { throw new Error('Заглушка');}
Осталось сделать тоже самое с возвращаемым типом:
function concatenate(a: string | number, b: string | number): string | number { throw new Error('Заглушка');}
Функция теперь совместима с каждым из кейсов, компилятор с облегчением выдыхает.
Реализация кейсов
К этому моменту мы полностью разобрались с типами, пора убрать заглушку окончательно и приступить к написанию исполняемого кода.
Давайте последовательно реализуем каждый кейс.
Начнем с первого:
function concatenate(a: string | number, b: string | number): string | number { // Кейс 1 if (typeof a === 'string' && typeof b === 'string') { return `${a} ${b}`; }}
Поскольку теперь каждый аргумент имеет несколько типов, придётся уточнить их при помощи type guard, что избавит нас от кастинга в блоке if. Позже я расскажу как упростить такие проверки, а пока воспользуемся встроенными средствами.
Аналогично реализуем оставшиеся кейсы:
function concatenate(a: string | number, b: string | number): string | number { // Кейс 1 if (typeof a === 'string' && typeof b === 'string') { return `${a} ${b}`; } // Кейс 2 if (typeof a === 'number' && typeof b === 'number') { return a + b; } // Кейс 3 if ((typeof a === 'string' && typeof parseFloat(a) === 'number') && typeof b === 'number') { return parseFloat(a) + b; }}
Компилятор подсказывает, что не все ветки возвращают значение соответствующее ожидаемому.
Для этого явно создадим ошибку в конце:
function concatenate(a: string | number, b: string | number): string | number { // Кейс 1 if (typeof a === 'string' && typeof b === 'string') { return `${a} ${b}`; } // Кейс 2 if (typeof a === 'number' && typeof b === 'number') { return a + b; } // Кейс 3 if ((typeof a === 'string' && typeof parseFloat(a) === 'number') && typeof b === 'number') { return parseFloat(a) + b; } throw new Error('Кейс не поддерживается');}
В результате функция компилируется, но даже если во время выполнения в нее попадут аргументы несовместимых типов, разработчик сразу об этом узнает не погружаясь в отладку.
Упрощение
Используя перегрузки мы вынуждены приводить типы аргументов в рантайме — это могут быть очень простые проверки, могут быть сложные, но в любом случае такие конструкции будут повторяться.
Для нашего примера отлично подойдет sindresorhus/is, где есть все что нужно для проверки примитивов:
import { isString, isNumber, isNumericString } from '@sindresorhus/is';function concatenate(a: string | number, b: string | number): string | number { if (isString(a) && isString(b)) { return `${a} ${b}`; } if (isNumber(a) && isNumber(b)) { return a + b; } // Кейс 3 if (isNumericString(a) && isNumber(b)) { return parseFloat(a) + b; } throw new Error('Кейс не поддерживается');}
Таким образом, написание проверок нам почти ничего не стоило, а код стал читаться намного легче.
Для более сложных случаев можно использовать библиотеки для валидации схем, такие как Ow от того же автора или более распространенную Zod.
Обзор
Теперь, когда мы закончили с реализацией, можем внимательно проанализировать тело функции.
Видно, что структура кода следует простому шаблону с одним уровнем вложенности:
-
Проверка 1
-
Реализация кейса 1
-
-
Проверка 2
-
Реализация кейса 2
-
-
Проверка 3
-
Реализация кейса 3
-
-
Выброс ошибки
Используя именно такую иерархию легко сохранить читаемость и управлять сложностью, когда количество кейсов начнет разрастаться.
Перегрузка как контракт для ИИ
Сегодня всё больше кода пишется ИИ. Не хочу превращать эту главу в очередной спор о том, может ли ИИ заменить разработчика. Намного интереснее другой вопрос: как использовать возможности ИИ так, чтобы получать предсказуемый и качественный результат?
Сегодня ИИ прекрасно умеет генерировать код, но у него есть одна особенность: если не задать достаточно чёткие границы, он вполне может предложить несколько разных способов решить одну и ту же задачу ни один из которых не будет соответствовать архитектуре и API проекта.
Именно поэтому, я считаю, в эпоху генерации кода особенно полезны инструменты, которые позволяют формализовать ожидания разработчиков. Мы не пытаемся заставить ИИ писать код вслепую, мы задаём ему четкий контракт, а затем позволяем ему реализовать решение в рамках четко очерченных границ.
Получается простая схема: разработчик определяет границы, ИИ реализует поведение, TS проверяет, что результат этим границам соответствует.
Поэтому перегрузка может быть полезна не только разработчику, который будет пользоваться функцией, но и ИИ, который будет её реализовывать.
В конце концов не так важно чьими руками был написан код, если его поведение соответствует ожиданиям.
Заключение
Эта статья не ставила перед собой цель стать исчерпывающим руководством по перегрузкам в TypeScript. Для этого пришлось бы гораздо глубже погрузиться в детали, разобрать множество нюансов, что бы перегрузило вместо функций уже самого читателя.
Вместо этого хотелось показать, что за непривычным синтаксисом скрывается вполне понятный инструмент, который способен решить реальные задачи: сделать API ваших функций удобнее, упростить документацию, сохранить обратную совместимость и даже помочь получать более предсказуемый результат при генерации кода с помощью ИИ.
Надеюсь, теперь перегрузка выглядит не как очередная сложная возможность TypeScript, которую можно проигнорировать, а как инструмент, который стоит попробовать в своём проекте.
Пишите что думаете, буду рад обратной связи и вопросам.
ссылка на оригинал статьи https://habr.com/ru/articles/1096858/