$ sudo teach IT
МОДУЛЬ 4 · УРОК 6

Типизация callback-функций

Функции как параметры — как типизировать callback-и, использоватьgenerics для типобезопасности и создавать гибкие API.

⏱ ~25 минут 🎓 Средний уровень 📌 Функции

🎯 Что такое callback-функции?

Callback-функция — это функция, которую передают друг другу как аргумент, чтобы её позже вызвали. Это один из самых мощных паттернов в JavaScript/TypeScript, лежащий в основе асинхронного программирования, обработки событий и функционального стиля.

Вы уже встречались с callback-ами countless раз:

// Массивы
[1, 2, 3].map(n => n * 2);        // n => n * 2 — callback
[1, 2, 3].filter(n => n > 1);     // n => n > 1 — callback
[1, 2, 3].forEach(n => console.log(n)); // n => console.log(n) — callback

// События
button.addEventListener("click", handleClick); // handleClick — callback

// Асинхронность
setTimeout(() => console.log("Привет"), 1000); // () => console.log(...) — callback

// Промисы
fetch("/api").then(response => response.json()); // response => ... — callback

Без типизации callback-функций TypeScript не может проверить, что переданная функция имеет правильную сигнатуру. Это может привести к ошибкамво время выполнения.

// ❌ Без типизации — TypeScript не может проверить
function execute(callback: any) {
  callback("hello", 42); // Что если callback ожидает другие аргументы?
}

execute((name: string) => console.log(name)); // Ошибкаво время выполнения!

💡 Аналогия: Представьте, что вы отправляете письмо с открыткой для ответа. Если не указать формат ответа (какой текст, сколько слов), получатель может ответить как угодно. Типизация callback-ов — это «инструкция по формату ответа».

📝 Базовая типизация callback-функций

Типизация callback — это описание типа функции прямо в сигнатуре параметра.

Inline-типизация

// Простой callback — принимает строку, ничего не возвращает
function greetUser(name: string, callback: (name: string) => void): void {
  const greeting = "Привет, " + name + "!";
  callback(greeting);
}

// Вызов
greetUser("Анна", (msg) => {
  console.log(msg); // "Привет, Анна!"
});

// Callback с возвращаемым значением
function processArray(
  items: number[],
  processor: (item: number) => number
): number[] {
  return items.map(processor);
}

const doubled = processArray([1, 2, 3], (n) => n * 2);
console.log(doubled); // [2, 4, 6]

Тип callback через type alias

// Определяем тип callback-а отдельно
type Callback = (message: string) => void;
type ErrorHandler = (error: Error) => void;
type DataProcessor = (data: T) => T;
type Predicate = (item: T) => boolean;

// Используем в функциях
function log(message: string, callback: Callback): void {
  callback(message);
}

function fetchData(url: string, onError: ErrorHandler): void {
  try {
    // загрузка...
  } catch (error) {
    onError(error as Error);
  }
}

function transform(
  data: T[],
  processor: DataProcessor
): T[] {
  return data.map(processor);
}

Тип callback через interface

// Интерфейс для функции (callable interface)
interface Formatter {
  (input: string): string;
}

interface Comparator {
  (a: T, b: T): number;
}

interface AsyncLoader {
  (url: string): Promise;
}

// Использование
function sort(array: T[], comparator: Comparator): T[] {
  return [...array].sort(comparator);
}

const numbers = [3, 1, 4, 1, 5, 9];
sort(numbers, (a, b) => a - b);  // [1, 1, 3, 4, 5, 9]
sort(numbers, (a, b) => b - a);  // [9, 5, 4, 3, 1, 1]

🔧 Callback сgenerics (Generics)

Дженерик (generics) позволяют создавать типобезопасные callback-и, которые работают с любыми типами данных.

map — маппинг значений

// Типобезопасный map
function map(array: T[], transform: (item: T) => R): R[] {
  return array.map(transform);
}

// Использование
const numbers = [1, 2, 3, 4, 5];

// Число → строка
const strings = map(numbers, (n) => n.toString());
// strings: string[]

// Число → boolean
const isEvens = map(numbers, (n) => n % 2 === 0);
// isEvens: boolean[]

// Строка → объект
const lengths = map(["hello", "world"], (s) => ({ text: s, length: s.length }));
// lengths: { text: string; length: number }[]

filter — фильтрация

// Типобезопасный filter
function filter(array: T[], predicate: (item: T) => boolean): T[] {
  return array.filter(predicate);
}

// Использование
const users = [
  { name: "Анна", age: 25, active: true },
  { name: "Борис", age: 30, active: false },
  { name: "Вика", age: 22, active: true }
];

// Фильтрация по условию
const activeUsers = filter(users, (u) => u.active);
// activeUsers: typeof users

// Фильтрация по age
const adults = filter(users, (u) => u.age >= 18);
// adults: typeof users

reduce — редуция

// Типобезопасный reduce
function reduce(
  array: T[],
  reducer: (accumulator: R, item: T) => R,
  initialValue: R
): R {
  return array.reduce(reducer, initialValue);
}

// Использование
const numbers = [1, 2, 3, 4, 5];

// Сумма
const sum = reduce(numbers, (acc, n) => acc + n, 0);
// sum: number

// Объект-счётчик
const wordCount = reduce(
  ["hello", "world", "hello", "typescript"],
  (acc, word) => {
    acc[word] = (acc[word] || 0) + 1;
    return acc;
  },
  {} as Record
);
// wordCount: Record

find — поиск

// Типобезопасный find
function find(
  array: T[],
  predicate: (item: T) => boolean
): T | undefined {
  return array.find(predicate);
}

// Использование
const numbers = [1, 2, 3, 4, 5];

const found = find(numbers, (n) => n > 3);
// found: number | undefined

if (found !== undefined) {
  console.log(found); // 4
}

🔗 Callback с несколькими параметрами

Callback-функции могут принимать несколько параметров. Давайте разберём разные сценарии.

Callback с индексом

// forEach с индексом
function forEach(
  array: T[],
  callback: (item: T, index: number) => void
): void {
  for (let i = 0; i < array.length; i++) {
    callback(array[i], i);
  }
}

// Использование
forEach(["а", "б", "в"], (letter, index) => {
  console.log(`${index + 1}. ${letter}`);
});
// 1. а
// 2. б
// 3. в

Callback с несколькими аргументами

// Обработчик событий с типизацией
type EventHandler = (event: T, timestamp: number) => void;

function onEvent(
  eventName: string,
  handler: EventHandler
): void {
  console.log(`Регистрация обработчика для ${eventName}`);
  // Предположим, что event вызывается позже
  // handler(event, Date.now());
}

// Использование
onEvent<{ type: string; value: number }>("data", (event, timestamp) => {
  console.log(`${timestamp}: ${event.type} = ${event.value}`);
});

// Callback с тремя параметрами
function sortWithKey(
  array: T[],
  keyFn: (item: T) => K,
  order: "asc" | "desc" = "asc"
): T[] {
  return [...array].sort((a, b) => {
    const keyA = keyFn(a);
    const keyB = keyFn(b);
    const comparison = keyA < keyB ? -1 : keyA > keyB ? 1 : 0;
    return order === "asc" ? comparison : -comparison;
  });
}

const users = [
  { name: "Вика", age: 22 },
  { name: "Анна", age: 25 },
  { name: "Борис", age: 30 }
];

sortWithKey(users, (u) => u.age, "desc");
// [{ name: "Борис", age: 30 }, { name: "Анна", age: 25 }, { name: "Вика", age: 22 }]

Callback с переменным числом аргументов

// Функция, принимающая callback с rest-параметрами
function processAll(
  items: T[],
  processor: (...args: T[]) => void
): void {
  processor(...items);
}

// Использование
processAll([1, 2, 3], (a, b, c) => {
  console.log(`Сумма: ${a + b + c}`);
});

// Callback с rest-параметрами
function logAll(
  level: string,
  ...messages: unknown[]
): void {
  console.log(`[${level}]`, ...messages);
}

// Передаём как callback
const logger: (level: string, ...messages: unknown[]) => void = logAll;
logger("info", "Сервер", "запущен", "на", "порту", 3000);

🔖 Callback и контекст this

Одна из самых частых проблем с callback-ами — потеря контекста this. TypeScript помогает избежать этой проблемы через типизацию.

Проблема потери this

class EventEmitter {
  private listeners: Record = {};

  // ❌ Проблема: this в callback-е не EventEmitter
  on(event: string, callback: Function): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event].push(callback);
  }

  emit(event: string): void {
    const callbacks = this.listeners[event] || [];
    callbacks.forEach(cb => cb()); // this = undefined (или window)
  }
}

Решение 1: Стрелочные функции

class EventEmitter {
  private listeners: Record = {};

  // ✅ Стрелочная функция сохраняет this
  on(event: string, callback: () => void): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event].push(callback);
  }

  emit(event: string): void {
    const callbacks = this.listeners[event] || [];
    callbacks.forEach(cb => cb()); // cb — стрелочная, this сохраняется
  }
}

Решение 2: Типизация this в callback

// Типизируем this для callback-а
type ThisCallback = (this: T) => void;

class Logger {
  private prefix: string;

  constructor(prefix: string) {
    this.prefix = prefix;
  }

  // Принимаем callback с типизированным this
  log(message: string, callback: ThisCallback): void {
    console.log(`${this.prefix}: ${message}`);
    callback.call(this); // Привязываем this к Logger
  }
}

const logger = new Logger("INFO");
logger.log("Загрузка", function() {
  // this: Logger — работает!
  console.log(this.prefix); // "INFO"
});

Решение 3: Стрелочные функции в интерфейсах

// Интерфейс с типом this
interface Handler {
  (this: TContext, event: Event): void;
}

class Button {
  private handlers: Handler[] = [];

  onClick(handler: Handler): void {
    this.handlers.push(handler);
  }

  click(): void {
    this.handlers.forEach(handler => {
      handler.call(this, new Event("click"));
    });
  }
}

const button = new Button();
button.onClick(function() {
  // this: Button — благодаря типизации
  console.log("Кнопка нажата!");
});

⏳ Промисы и async callback-функции

В современном TypeScript callback-и всё чаще заменяются промисами и async/await. Но типизация промисов — это тоже типизация callback-ов.

Типизация промисов

// Функция, возвращающая промис
function fetchUser(id: string): Promise<{ id: string; name: string }> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id === "0") {
        reject(new Error("Неверный ID"));
      } else {
        resolve({ id, name: "Пользователь " + id });
      }
    }, 1000);
  });
}

// Callback с промисом
function loadAndProcess(
  id: string,
  onSuccess: (user: { id: string; name: string }) => void,
  onError: (error: Error) => void
): void {
  fetchUser(id)
    .then(onSuccess)
    .catch(onError);
}

// Использование
loadAndProcess(
  "123",
  (user) => console.log("Пользователь:", user.name),
  (error) => console.error("Ошибка:", error.message)
);

Async/await вместо callback

// Вместо callback — промисы + async/await
async function loadUser(id: string): Promise<{ id: string; name: string }> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error("Пользователь не найден");
  }
  return response.json();
}

// Использование
async function main() {
  try {
    const user = await loadUser("123");
    console.log(user.name);
  } catch (error) {
    console.error(error);
  }
}

// Функция с callback-ом → промис
function withTimeout(
  promise: Promise,
  timeout: number
): Promise {
  return Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error("Timeout")), timeout)
    )
  ]);
}

// Использование
const user = await withTimeout(fetchUser("123"), 5000);

🏗️ Паттерны с callback-ами

Паттерн 1: Visitor

// Паттерн Visitor: обход дерева с callback-ом
interface TreeNode {
  value: number;
  left?: TreeNode;
  right?: TreeNode;
}

type Visitor = (node: TreeNode) => void;

function traverse(node: TreeNode | undefined, visitor: Visitor): void {
  if (!node) return;
  visitor(node);
  traverse(node.left, visitor);
  traverse(node.right, visitor);
}

// Использование
const tree: TreeNode = {
  value: 1,
  left: { value: 2, left: { value: 4 }, right: { value: 5 } },
  right: { value: 3, left: { value: 6 }, right: { value: 7 } }
};

const values: number[] = [];
traverse(tree, (node) => values.push(node.value));
console.log(values); // [1, 2, 4, 5, 3, 6, 7]

Паттерн 2: Strategy

// Паттерн Strategy: разные стратегии сортировки
type SortStrategy = (a: T, b: T) => number;

function sort(array: T[], strategy: SortStrategy): T[] {
  return [...array].sort(strategy);
}

// Стратегии
const byAge: SortStrategy<{ name: string; age: number }> = (a, b) => a.age - b.age;
const byName: SortStrategy<{ name: string; age: number }> = (a, b) => a.name.localeCompare(b.name);
const byAgeDesc: SortStrategy<{ name: string; age: number }> = (a, b) => b.age - a.age;

// Использование
const people = [
  { name: "Вика", age: 22 },
  { name: "Анна", age: 25 },
  { name: "Борис", age: 30 }
];

sort(people, byAge);      // [{ name: "Вика", age: 22 }, { name: "Анна", age: 25 }, { name: "Борис", age: 30 }]
sort(people, byName);     // [{ name: "Анна", age: 25 }, { name: "Борис", age: 30 }, { name: "Вика", age: 22 }]
sort(people, byAgeDesc);  // [{ name: "Борис", age: 30 }, { name: "Анна", age: 25 }, { name: "Вика", age: 22 }]

Паттерн 3: Middleware

// Паттерн Middleware: цепочка обработки
type Middleware = (data: T, next: () => void) => void;

function createPipeline(...middlewares: Middleware[]): (data: T) => void {
  return (data: T) => {
    let index = 0;

    function next(): void {
      if (index < middlewares.length) {
        const middleware = middlewares[index++];
        middleware(data, next);
      }
    }

    next();
  };
}

// Использование
const log: Middleware = (data, next) => {
  console.log("Лог:", data);
  next();
};

const validate: Middleware = (data, next) => {
  if (data.length > 0) {
    next();
  } else {
    console.log("Ошибка: пустые данные");
  }
};

const save: Middleware = (data, next) => {
  console.log("Сохранение:", data);
  next();
};

const pipeline = createPipeline(log, validate, save);
pipeline("Hello!");  // Лог: Hello! → Сохранение: Hello!
pipeline("");         // Лог:  → Ошибка: пустые данные

Паттерн 4: Event Emitter

// Типизированный EventEmitter
type EventMap = Record;

class TypedEmitter {
  private listeners: { [K in keyof Events]?: Array<(...args: Events[K]) => void> } = {};

  on(
    event: K,
    listener: (...args: Events[K]) => void
  ): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }

  emit(event: K, ...args: Events[K]): void {
    const callbacks = this.listeners[event] || [];
    for (const callback of callbacks) {
      callback(...args);
    }
  }

  off(
    event: K,
    listener: (...args: Events[K]) => void
  ): void {
    const callbacks = this.listeners[event] || [];
    this.listeners[event] = callbacks.filter(cb => cb !== listener) as any;
  }
}

// Использование
interface AppEvents {
  login: [userId: string];
  logout: [userId: string];
  error: [code: number, message: string];
}

const emitter = new TypedEmitter();

emitter.on("login", (userId) => {
  console.log("Вошёл:", userId);
});

emitter.on("error", (code, message) => {
  console.error(`Ошибка ${code}: ${message}`);
});

emitter.emit("login", "user123");      // ✅ OK
emitter.emit("error", 404, "Не найдено"); // ✅ OK
// emitter.emit("error", "bad");  // ❌ Ошибка типа

⚠️ Callback и обработка ошибок

Типизация callback-ов помогает правильно обрабатывать ошибки. Рассмотрим паттерны.

Node.js-style callback (error-first)

// Node.js стиль: первый аргумент — ошибка
type NodeCallback = (error: Error | null, result?: T) => void;

function readFile(
  path: string,
  callback: NodeCallback
): void {
  try {
    const content = "Содержимое файла"; // имитация
    callback(null, content);
  } catch (error) {
    callback(error as Error);
  }
}

// Использование
readFile("/etc/config", (error, result) => {
  if (error) {
    console.error("Ошибка:", error.message);
    return;
  }
  // result: string | undefined
  console.log(result); // "Содержимое файла"
});

Promise-style callback

// Promise стиль: resolve/reject
function fetchData(
  url: string,
  resolve: (data: T) => void,
  reject: (error: Error) => void
): void {
  fetch(url)
    .then(res => res.json())
    .then(data => resolve(data as T))
    .catch(err => reject(err));
}

// Использование
fetchData<{ users: string[] }>("/api/users",
  (data) => console.log(data.users),
  (error) => console.error(error.message)
);

Result callback (не-исключения)

// Result тип: Success | Failure — без исключений
type Result =
  | { ok: true; value: T }
  | { ok: false; error: string };

type ResultCallback = (result: Result) => void;

function divide(
  a: number,
  b: number,
  callback: ResultCallback
): void {
  if (b === 0) {
    callback({ ok: false, error: "Деление на ноль" });
  } else {
    callback({ ok: true, value: a / b });
  }
}

// Использование — нет try/catch
divide(10, 2, (result) => {
  if (result.ok) {
    console.log("Результат:", result.value); // 5
  } else {
    console.log("Ошибка:", result.error);
  }
});

divide(10, 0, (result) => {
  if (result.ok) {
    console.log("Результат:", result.value);
  } else {
    console.log("Ошибка:", result.error); // "Деление на ноль"
  }
});

❌ Частые ошибки

Ошибка 1: Нетипизированный callback

// ❌ Плохо — callback без типизации
function process(data: unknown, callback: Function) {
  callback(data); // Что если callback ожидает другой тип?
}

// ✅ Хорошо — явная типизация callback
function process(data: T, callback: (data: T) => void) {
  callback(data); // TypeScript знает тип data
}

Ошибка 2: Несовпадение типов callback

// ❌ Ошибка типа
function execute(callback: (x: number) => string) {
  return callback(42);
}

execute((x) => x * 2); // Ошибка: number не assignable к string

// ✅ Правильно
execute((x) => x.toString());
execute((x) => `Число: ${x}`);

Ошибка 3: Забыли return в callback

// ❌ Callback ничего не возвращает
const numbers = [1, 2, 3];
const doubled = numbers.map((n) => {
  n * 2; // Забыли return!
});
// doubled: [undefined, undefined, undefined]

// ✅ Правильно — неявный или явный return
const doubled1 = numbers.map((n) => n * 2);
const doubled2 = numbers.map((n) => { return n * 2; });

Ошибка 4: Потеря this в callback

class Timer {
  seconds = 0;

  // ❌ this потеряется
  start(): void {
    setInterval(function() {
      this.seconds++; // this ≠ Timer
    }, 1000);
  }

  // ✅ Исправление — стрелочная функция
  startFixed(): void {
    setInterval(() => {
      this.seconds++; // this = Timer
    }, 1000);
  }

  // ✅ Альтернатива — bind
  startBound(): void {
    setInterval(function() {
      this.seconds++; // this = Timer (благодаря bind)
    }.bind(this), 1000);
  }
}

📋 Итоги урока

  • Callback-функция — функция, переданная как аргумент другой функции
  • Типизация: описывайте тип callback inline или через type alias
  • generics (Generics) — создают типобезопасные callback-и для любых типов
  • this: стрелочные функции сохраняют контекст, обычные — нет
  • Промисы: заменяют callback-и в асинхронном коде
  • Паттерны: Visitor, Strategy, Middleware, Event Emitter — всё через callback-и
  • Ошибки: типизируйте callback, проверяйте типы, не теряйте this

✅ Ключевое правило: Типизация callback-ов — это контракт между вызывающим и вызываемым кодом. Чётко описывайте, какие аргументы ожидает callback и что он возвращает. Это делает код безопасным и самодокументированным.

🎉 Поздравляем! Вы завершили Модуль 4: Функции. Теперь вы умеете типизировать параметры и возвращаемые значения, работать с необязательными параметрами, rest-параметрами, перегрузкой функций, стрелочными функциями и callback-ами. Это основа для создания типобезопасных и гибких API.

Проверяем понимание

6 вопросов

Функция с callback

Premium