Урок 13: Перегрузки функций (Function Overloads) в TypeScript

Сегодня нас ждет одна из самых интересных и можно даже сказать «магических» тем, это перегрузки функций. Если вы когда-нибудь слышали этот термин в контексте других языков, вроде Java или C#, то не пугайтесь. В TypeScript это реализовано несколько иначе, но суть остается прежней, одна функция, множество лиц.

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

Зачем нужны перегрузки функций?

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

Первое, что приходит в голову, использовать union types.

typescript
function getStringValue(input: string | string[]): string {
    if (typeof input === 'string') {
        return input.toUpperCase();
    } else if (Array.isArray(input)) {
        return input.join(' ').toUpperCase();
    }
    // На всякий случай, хотя сюда мы никогда не попадем
    return '';
}

const result1 = getStringValue('hello'); // type: string, value: "HELLO"
const result2 = getStringValue(['hello', 'world']); // type: string, value: "HELLO WORLD"

Вроде бы все работает. Но давайте посмотрим на эту функцию с точки зрения того, кто будет ее использовать. Внутри функции мы прекрасно понимаем, что ветка else if обрабатывает массив. Однако для внешнего кода эта информация теряется. Оба вызова возвращают просто string.

А что, если мы захотим, чтобы функция возвращала не просто string, а нечто более специфичное? Например, если на вход пришла строка, вернуть просто string, а если массив, вернуть объект типа { concatenated: string; length: number }? Union type в сигнатуре нам уже не поможет, потому что он описывает только входные параметры, но не связывает их жестко с выходными.

Вот здесь и появляется необходимость в перегрузке функций. Мы хотим явно описать несколько вариантов работы функции: «Если вызвали вот с такими аргументами, вернется вот такой результат. Если с другими, вернется другой результат». TypeScript сможет понять эти подписи и предоставить правильный автокомплит и проверку типов в зависимости от контекста вызова.

Концепция перегрузки: одна функция, несколько сигнатур

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

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

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

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

typescript
// Сигнатуры перегрузки (Overload Signatures)
function getStringValue(input: string): string;
function getStringValue(input: string[]): string;

// Реализация (Implementation Signature)
function getStringValue(input: unknown): string {
    if (typeof input === 'string') {
        return input.toUpperCase();
    } else if (Array.isArray(input)) {
        return input.join(' ').toUpperCase();
    }
    return '';
}

const result1 = getStringValue('hello'); // TypeScript теперь *точно* знает, что result1 - string
const result2 = getStringValue(['hello', 'world']); // и result2 - тоже string

Пока что результат работы кода не изменился. Но посмотрите, как мы объявили функцию. Сначала мы сказали: «Эту функцию можно вызвать двумя способами: со строкой и она вернет строку или с массивом строк и она тоже вернет строку». Затем мы написали реализацию, которая принимает unknown (самый безопасный тип для реализации перегрузок, как мы увидим позже) и внутри уже разбирается, что к чему.

Ключевой момент: внешний код видит только сигнатуры перегрузки. Реализационная сигнатура function getStringValue(input: unknown): string скрыта от того, кто вызывает функцию. Это значит, что попытка вызвать getStringValue(42) приведет к ошибке на этапе компиляции, даже если бы наша реализация умела обрабатывать числа, потому что ни одна из публичных сигнатур не разрешает передачу числа.

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

typescript
// Сигнатуры перегрузки
function getStringValue(input: string): string;
function getStringValue(input: string[]): { concatenated: string; length: number };

// Реализация
function getStringValue(input: unknown): string | { concatenated: string; length: number } {
    if (typeof input === 'string') {
        return input.toUpperCase();
    } else if (Array.isArray(input)) {
        const concatenated = input.join(' ').toUpperCase();
        return { concatenated, length: concatenated.length };
    }
    throw new Error('Invalid input type');
}

const result1 = getStringValue('hello'); // type: string
const result2 = getStringValue(['hello', 'world']); // type: { concatenated: string; length: number }

console.log(result1.toUpperCase()); // OK, result1 - string
console.log(result2.concatenated); // OK, result2 - объект с полем concatenated
// console.log(result2.toUpperCase()); // ОШИБКА: Property 'toUpperCase' does not exist on type...

TypeScript проанализировал сигнатуры перегрузки и в зависимости от аргумента вызова точно определил тип возвращаемого значения.

  • result1 это строка и мы можем вызывать на ней .toUpperCase().

  • result2 это объект с полями concatenated и length и TypeScript ругается, если мы пытаемся работать с ним как со строкой.

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

Синтаксис перегрузки: сигнатуры и реализация

Давайте формализуем синтаксис, который мы уже увидели в примерах.

  1. Сигнатуры перегрузки (Overload Signatures): Это объявления функции без тела. Их может быть две и более. Они описывают все возможные способы вызова функции, которые мы хотим поддерживать. Важно, чтобы сигнатуры перегрузки были совместимы друг с другом с точки зрения последующей реализации (обычно они имеют общее возвращаемое значение или общие параметры, но не обязательно).

  2. Сигнатура реализации (Implementation Signature): Это одно объявление функции с телом, которое следует сразу после всех сигнатур перегрузки. Эта сигнатура не видна извне. Ее задача реализовать логику для всех описанных выше случаев. Типы параметров в реализации должны быть достаточно широкими, чтобы покрыть все варианты из сигнатур перегрузки (чаще всего используется any или unknown).

Порядок важен: сначала все overload signatures, потом implementation signature.

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

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

Типичные сценарии использования перегрузок функций

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

1. Обработка различных типов входных данных с разными возвращаемыми типами.
Это наш основной пример выше. Функции, которые могут принимать string | number | array и возвращать принципиально разные объекты в ответ идеальные кандидаты на перегрузку.

2. Создание библиотек и универсальных утилит.
Чаще всего перегрузки встречаются в описаниях типов для популярных библиотек (например, в самих типах TypeScript для методов массива like find). Представьте функцию querySelector:

typescript
// Где-то в недрах lib.dom.d.ts
querySelector(selectors: string): Element | null;
querySelector(selectors: 'div'): HTMLDivElement | null;
querySelector(selectors: '.my-class'): Element | null;
// ... и еще много перегрузок

Она использует перегрузки, чтобы возвращать максимально конкретный тип элемента в зависимости от переданного селектора.

3. Уточнение типов при работе с промисами.
Функция, которая оборачивает что-то в промис, может использовать перегрузки для точного описания разрешаемого значения.

typescript
function createPromise(value: string): Promise<string>;
function createPromise(value: number): Promise<number>;
function createPromise(value: unknown): Promise<unknown> {
    return Promise.resolve(value);
}

const stringPromise = createPromise('hello'); // Promise<string>
const numberPromise = createPromise(42); // Promise<number>

Ограничения и лучшие практики

Перегрузки мощный инструмент, но и с ним нужно уметь обращаться.

Используйте unknown или any в реализации.
Типы параметров в сигнатуре реализации должны быть достаточно широкими. unknown предпочтительнее, так как он безопаснее и заставляет вас делать проверки типов внутри функции.

Плохо:

typescript
function badExample(x: string): string;
function badExample(x: number): number;
function badExample(x: string | number): string | number { // Тип параметра уже норм, но...
    // ...здесь придется делать проверки, иначе TS будет ругаться на возвращаемый тип
    if (typeof x === 'string') {
        return x.toUpperCase();
    }
    return x * 2;
}

Хорошо:

typescript
function goodExample(x: string): string;
function goodExample(x: number): number;
function goodExample(x: unknown): string | number {
    // Мы *вынуждены* проверить тип, т.к. работаем с unknown
    if (typeof x === 'string') {
        return x.toUpperCase();
    } else if (typeof x === 'number') {
        return x * 2;
    }
    throw new Error('Invalid input');
}

Не злоупотребляйте

Если вашу задачу можно решить с помощью объединенных типов (union types) и проверок типов внутри одной функции без усложнения, возможно так и стоит поступить. Перегрузки добавляют коду сложности и многострочности. Используйте их там, где это действительно дает преимущество в типизации.

Читабельность

Если перегрузок становится очень много (больше 5-7), возможно, стоит подумать о рефакторинге и разбиении функции на несколько более специализированных.

Практические примеры и задачи

Давайте закрепим материал на реальных кейсах.

Задача 1: Функция-форматер дат.
Создайте функцию formatDate, которая может принимать:

  1. Объект типа Date и строку формата (например, 'yyyy-mm-dd').

  2. Число (timestamp) и строку формата.

  3. Строку (parseable date string) и строку формата.
    Функция всегда должна возвращать отформатированную строку. Напишите перегрузки для этой функции.

Решение:

typescript
// Сигнатуры перегрузки
function formatDate(date: Date, format: string): string;
function formatDate(timestamp: number, format: string): string;
function formatDate(dateString: string, format: string): string;

// Реализация (упрощенная, без реального форматирования)
function formatDate(input: unknown, format: string): string {
    let date: Date;

    // Приводим неизвестный вход к Date
    if (input instanceof Date) {
        date = input;
    } else if (typeof input === 'number') {
        date = new Date(input);
    } else if (typeof input === 'string') {
        date = new Date(input);
    } else {
        throw new Error('Invalid input type for date');
    }

    // Здесь должна быть сложная логика форматирования на основе `format`
    // Для примера просто вернем toISOString
    return `Formatted [${date.toISOString()}] with pattern '${format}'`;
}

const dateFormatted = formatDate(new Date(), 'yyyy-mm-dd');
const timestampFormatted = formatDate(1672531200000, 'yyyy-mm-dd');
const stringFormatted = formatDate('2023-01-01', 'yyyy-mm-dd');

Задача 2: Функция add (сложение).
Создайте функцию add, которая может:

  1. Сложить два числа.

  2. Сложить две строки (конкатенация).

  3. Сложить число и строку (в результате строка, где число прибавлено к строке: add(5, '10') -> '510').
    Напишите соответствующие перегрузки.

Решение:

typescript
// Сигнатуры перегрузки
function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a: number, b: string): string;
function add(a: string, b: number): string;

// Реализация
function add(a: unknown, b: unknown): number | string {
    if (typeof a === 'number' && typeof b === 'number') {
        return a + b;
    }
    // Для всех остальных случаев преобразуем к строке и конкатенируем
    return String(a) + String(b);
}

const sumNumbers = add(2, 3); // number
const sumStrings = add('Hello, ', 'World!'); // string
const mixedSum = add(5, ' apples'); // string

Задача 3 (посложнее): Функция get для объекта.
Напишите функцию get, которая принимает объект и ключ. Она должна возвращать значение по этому ключу. Используйте дженерики и перегрузки, чтобы тип возвращаемого значения точно соответствовал типу значения свойства объекта.

Здесь нам помогут дженерики из прошлых уроков.

typescript
const person = { name: 'Max', age: 30 };

// Хотим, чтобы это работало так:
const name = get(person, 'name'); // type: string
const age = get(person, 'age'); // type: number
// const unknown = get(person, 'unknown'); // Ошибка: такого ключа нет

Решение:

typescript
// Объявляем вспомогательный тип для ключей объекта
type ObjectKeys<T> = keyof T;

// Сигнатуры перегрузки (здесь они по сути дублируются, но это нужно для совместимости)
function get<T, K extends ObjectKeys<T>>(obj: T, key: K): T[K];
function get<T, K extends keyof T>(obj: T, key: K): T[K];

// Реализация
function get(obj: unknown, key: unknown): unknown {
    // Проверяем, что obj - это объект, а key - это строка или число
    if (typeof obj === 'object' && obj !== null && (typeof key === 'string' || typeof key === 'number')) {
        return (obj as Record<string | number, unknown>)[key];
    }
    throw new Error('Invalid arguments');
}

const person = { name: 'Max', age: 30 };
const name = get(person, 'name'); // string
const age = get(person, 'age'); // number
// get(person, 'salary'); // Ошибка компиляции: Argument of type '"salary"' is not assignable to parameter of type '"name" | "age"'

Этот пример показывает, как перегрузки можно комбинировать с дженериками для создания невероятно мощных и типобезопасных утилит.

Заключение

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

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

Хочешь освоить TypeScript от основ до продвинутого уровня?
Переходи к полному курсу по TypeScript для начинающих на моем сайте! Там тебя ждут все 40 уроков, упражнения, проекты и многое другое.

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий