Асинхронне програмування у Node.js

Callbacks — перша парадигма асинхронності

Error-First Callback pattern, Callback Hell, проблеми та обмеження

Callbacks — перша парадигма асинхронності

🎯 Мета лекції

  • Опанувати концепцію callback-функцій як механізм асинхронної комунікації.
  • Зрозуміти Error-First Callback pattern — стандарт Node.js до появи промісів.
  • Навчитися розпізнавати та уникати антипаттерну Callback Hell (Pyramid of Doom).
  • Дослідити проблеми обробки помилок та контролю потоку виконання у callback-based коді.
  • Розуміти історичний контекст переходу від callbacks до промісів та async/await.

🔑 Ключові терміни

  • Callback (функція зворотного виклику): функція, що передається як аргумент іншій функції для виконання після завершення асинхронної операції.
  • Error-First Callback: конвенція Node.js, де перший параметр callback завжди містить помилку (null у разі успіху), а другий — результат операції.
  • Callback Hell (пекло колбеків): антипаттерн, коли вкладені callback призводять до важкочитабельного коду з глибокою індентацією.
  • Inversion of Control (інверсія керування): ситуація, коли контроль над виконанням коду передається зовнішній функції через callback.

Історичний контекст: асинхронність у ранніх версіях Node.js

Коли Раян Даль (Ryan Dahl) представив Node.js у 2009 році, мова JavaScript ще не мала вбудованої підтримки промісів (Promises) на рівні стандарту ECMAScript. Специфікація ES6 (ECMAScript 2015), яка офіційно додала Promise до мови, з'явилася лише у червні 2015 року. До цього моменту єдиним загальноприйнятим механізмом для роботи з асинхронним кодом у JavaScript були callback-функції (функції зворотного виклику).

Цей підхід був успадкований з браузерного JavaScript, де callback використовувалися для обробки подій DOM (Document Object Model), таймерів та AJAX-запитів. Наприклад, у браузері:

// Браузерний приклад (до появи fetch API)
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users');

xhr.onload = function() {
  // Callback, що виконується після завершення запиту
  if (xhr.status === 200) {
    console.log('Дані отримано:', xhr.responseText);
  }
};

xhr.onerror = function() {
  // Callback для обробки помилок
  console.error('Помилка запиту');
};

xhr.send();

Node.js прийняв callback як основний механізм асинхронності, але запровадив чітку конвенцію для уніфікації обробки помилок — Error-First Callback pattern. Це дозволило стандартизувати інтерфейси всіх асинхронних API у екосистемі та створити передбачувану поведінку для розробників.

Важливо розуміти, що callback не є «застарілою» технологією, яку не варто вивчати. Навіть сьогодні багато бібліотек (наприклад, стрім-API Node.js, низькорівневі бібліотеки) використовують callback для максимальної гнучкості та продуктивності. Крім того, розуміння callback є необхідним для опанування промісів та async/await, оскільки ці абстракції побудовані саме поверх callback-based моделі.

Анатомія callback-функції

Callback (функція зворотного виклику) — це звичайна функція JavaScript, яка передається як аргумент іншій функції. Викликаюча функція (caller) виконує асинхронну операцію та після її завершення (успішного або з помилкою) викликає переданий callback, передаючи йому результат або інформацію про помилку.

Розглянемо найпростіший приклад callback, що не пов'язаний з асинхронністю:

function greet(name: string, callback: (message: string) => void): void {
  const message = `Привіт, ${name}!`;
  callback(message); // Викликаємо callback і передаємо йому результат
}

// Використання
greet('Олексій', (msg) => {
  console.log(msg); // Вивід: Привіт, Олексій!
});

У цьому прикладі функція greet приймає два аргументи: рядок name та функцію callback. Після формування привітання функція викликає callback, передаючи йому сформований текст. Це ілюструє базовий принцип: callback дозволяє викликаючій функції повернути результат не через return, а через виклик переданої функції.

Тепер розглянемо асинхронний приклад із таймером:

function delayedGreeting(name: string, callback: (message: string) => void): void {
  console.log('Запуск таймера...');
  
  setTimeout(() => {
    const message = `Привіт, ${name}! (з затримкою)`;
    callback(message);
  }, 2000); // Затримка 2 секунди
  
  console.log('Функція delayedGreeting завершила виконання');
}

// Використання
delayedGreeting('Марія', (msg) => {
  console.log(msg);
});

console.log('Основний код продовжує виконання');

Порядок виконання:

Запуск таймера...
Функція delayedGreeting завершила виконання
Основний код продовжує виконання
Привіт, Марія! (з затримкою)

Як видно, функція delayedGreeting завершує своє виконання майже миттєво (вона лише реєструє таймер), а callback викликається пізніше, коли Event Loop обробляє спрацювання таймера через 2 секунди. Це демонструє ключову властивість асинхронних callback: вони відокремлюють момент ініціації операції від моменту отримання результату.

У синхронному коді результат повертається через return одразу після виконання функції. В асинхронному коді результат передається через callback у невизначений момент у майбутньому, коли операція завершиться.

Error-First Callback Pattern: стандарт Node.js

Однією з найважливіших конвенцій Node.js є Error-First Callback pattern — угода про те, як повинні виглядати сигнатури callback-функцій. Згідно з цим патерном, перший параметр callback завжди зарезервований для помилки, а наступні параметри — для успішного результату операції.

Стандартна сигнатура Error-First Callback виглядає так:

type ErrorFirstCallback<T> = (error: Error | null, result?: T) => void;

Якщо операція завершилася успішно, перший параметр містить null, а результат передається у другому параметрі. Якщо сталася помилка, перший параметр містить об'єкт Error, а другий параметр може бути undefined або взагалі відсутній.

Розглянемо класичний приклад читання файлу з модуля fs:

import { readFile } from 'node:fs';

readFile('./config.json', 'utf-8', (error, data) => {
  if (error) {
    // Обробка помилки (файл не знайдено, немає прав доступу тощо)
    console.error('Помилка читання файлу:', error.message);
    return; // Важливо: припиняємо виконання callback
  }
  
  // Якщо помилки немає, обробляємо дані
  try {
    const config = JSON.parse(data);
    console.log('Конфігурація завантажена:', config);
  } catch (parseError) {
    console.error('Помилка парсингу JSON:', parseError);
  }
});

Чому перший параметр саме помилка? Це архітектурне рішення має кілька важливих переваг:

  1. Форсування обробки помилок: розробник змушений одразу перевірити наявність помилки на початку callback, що знижує ймовірність «німого» ігнорування помилок.
  2. Уніфікація API: усі асинхронні функції у Node.js та екосистемі npm дотримуються цієї конвенції, що робить код передбачуваним.
  3. Явна обробка виключень: на відміну від синхронного коду з блоками try/catch, у асинхронному callback-based коді виключення, що виникають всередині callback, не можуть бути перехоплені зовнішнім блоком try/catch. Error-First pattern робить обробку помилок явною.
Асинхронні помилки не можна перехопити через try/catch, що огортає виклик асинхронної функції. Наприклад, цей код не спрацює:
try {
  readFile('./config.json', 'utf-8', (error, data) => {
    throw new Error('Щось пішло не так'); // Ця помилка НЕ буде перехоплена
  });
} catch (error) {
  console.error('Ця гілка ніколи не виконається');
}
Причина: на момент виконання callback основний код вже давно завершився. Помилка у callback призводить до необробленого виключення (unhandled exception) та аварійного завершення процесу Node.js.

Правильний спосіб обробки помилок — усередині самого callback:

readFile('./config.json', 'utf-8', (error, data) => {
  if (error) {
    // Обробка помилки читання файлу
    console.error('Помилка файлової системи:', error);
    return;
  }
  
  try {
    const config = JSON.parse(data);
    console.log(config);
  } catch (parseError) {
    // Обробка помилки парсингу JSON
    console.error('Помилка парсингу:', parseError);
  }
});

Вкладені callback та народження Callback Hell

Реальні застосунки рідко виконують лише одну асинхронну операцію. Частіше виникає необхідність виконати кілька послідовних асинхронних дій, де кожна наступна залежить від результату попередньої. Наприклад:

  1. Прочитати конфігурацію з файлу.
  2. Підключитися до бази даних, використовуючи параметри з конфігурації.
  3. Виконати SQL-запит для отримання даних користувача.
  4. Записати отримані дані у лог-файл.

У callback-based підході кожна наступна операція розміщується всередині callback попередньої:

import { readFile, writeFile } from 'node:fs';
import { Client } from 'pg'; // PostgreSQL клієнт

readFile('./config.json', 'utf-8', (configError, configData) => {
  if (configError) {
    console.error('Помилка читання конфігурації:', configError);
    return;
  }
  
  const config = JSON.parse(configData);
  const client = new Client({
    host: config.database.host,
    port: config.database.port,
    database: config.database.name,
  });
  
  client.connect((connectError) => {
    if (connectError) {
      console.error('Помилка підключення до БД:', connectError);
      return;
    }
    
    client.query('SELECT * FROM users WHERE id = $1', [42], (queryError, result) => {
      if (queryError) {
        console.error('Помилка виконання запиту:', queryError);
        client.end();
        return;
      }
      
      const userData = JSON.stringify(result.rows[0], null, 2);
      
      writeFile('./user-42.json', userData, 'utf-8', (writeError) => {
        if (writeError) {
          console.error('Помилка запису файлу:', writeError);
        } else {
          console.log('Дані користувача збережено у файл');
        }
        
        client.end(); // Закриваємо з'єднання
      });
    });
  });
});

Цей код функціонально коректний, але має серйозні проблеми з читабельністю та підтримкою. Кожен рівень вкладеності додає індентацію, утворюючи характерну піраміду коду, яку жартома називають Callback Hell (пекло колбеків) або Pyramid of Doom (піраміда приречених).

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

rectangle "readFile('./config.json', callback)" as R1 #DBEAFE {
  rectangle "client.connect(callback)" as R2 #E2E8F0 {
    rectangle "client.query(sql, callback)" as R3 #FEF3C7 {
      rectangle R4 #DCFCE7 [
      writeFile('./user-42.json', callback)
      --
      4 рівні вкладеності
      ]
    }
  }
}

@enduml

Проблеми цього коду:

  1. Погана читабельність: код «дрейфує» вправо з кожною новою операцією, що ускладнює відстеження логіки виконання.
  2. Дублювання обробки помилок: кожен callback містить власний блок перевірки помилок, що призводить до повторюваного коду.
  3. Складність рефакторингу: виділення частини логіки у окрему функцію вимагає передачі контексту через додаткові параметри.
  4. Проблеми з потоком керування: забути return після обробки помилки — типова помилка, що призводить до виконання коду у некоректному стані.
У наведеному прикладі легко забути закрити з'єднання з базою даних (client.end()) у разі помилки на проміжному етапі. Це призводить до витоку ресурсів (resource leak) — незакриті з'єднання накопичуються, і врешті-решт сервер вичерпує пул підключень до бази даних.

Проблема «забутого return» після callback

Однією з найпідступніших помилок у callback-based коді є відсутність return після виклику callback у гілці обробки помилки. Розглянемо приклад:

import { readFile } from 'node:fs';

function loadUserData(userId: number, callback: (error: Error | null, user?: any) => void): void {
  readFile(`./users/${userId}.json`, 'utf-8', (error, data) => {
    if (error) {
      callback(error); // ❌ ПОМИЛКА: немає return
      // Виконання продовжується далі!
    }
    
    // Цей код виконається навіть у разі помилки
    const user = JSON.parse(data); // TypeError: Cannot read property 'parse' of undefined
    callback(null, user); // callback викликається вдруге!
  });
}

У цьому коді відсутній return після виклику callback(error). Це означає, що навіть якщо сталася помилка читання файлу, виконання продовжиться далі, і спроба парсингу undefined призведе до виключення. Більш того, callback буде викликано двічі: спочатку з помилкою, потім з виключенням парсингу.

Правильний варіант:

function loadUserData(userId: number, callback: (error: Error | null, user?: any) => void): void {
  readFile(`./users/${userId}.json`, 'utf-8', (error, data) => {
    if (error) {
      callback(error);
      return; // ✅ Припиняємо виконання функції
    }
    
    try {
      const user = JSON.parse(data);
      callback(null, user);
    } catch (parseError) {
      callback(parseError as Error);
    }
  });
}

Або альтернативний стиль із раннім виходом (early return):

function loadUserData(userId: number, callback: (error: Error | null, user?: any) => void): void {
  readFile(`./users/${userId}.json`, 'utf-8', (error, data) => {
    if (error) return callback(error); // ✅ Одним рядком
    
    try {
      const user = JSON.parse(data);
      callback(null, user);
    } catch (parseError) {
      callback(parseError as Error);
    }
  });
}
Використання return callback(...) як єдиного виразу гарантує, що після виклику callback функція завершить виконання. Це усуває цілий клас помилок, пов'язаних із забутим return.

Інверсія керування: втрата контролю над виконанням

Callback-based підхід має фундаментальну архітектурну проблему, яку називають інверсією керування (inversion of control). Коли ви передаєте callback у функцію, ви передаєте їй контроль над тим, коли, скільки разів і в якому контексті цей callback буде викликано.

Розглянемо приклад із гіпотетичною бібліотекою для обробки платежів:

import { processPayment } from 'third-party-payment-lib';

function checkout(userId: number, amount: number): void {
  processPayment(userId, amount, (error, receipt) => {
    if (error) {
      console.error('Помилка платежу:', error);
      return;
    }
    
    // ❌ НЕБЕЗПЕКА: ми не контролюємо, скільки разів це виконається
    chargeCreditCard(userId, amount);
    sendConfirmationEmail(userId, receipt);
    updateInventory(receipt.items);
  });
}

У цьому коді ми покладаємося на те, що бібліотека processPayment коректно викличе наш callback рівно один раз після завершення операції. Але що, якщо:

  • Бібліотека містить баг і викликає callback двічі? Користувача буде списано подвійну суму.
  • Callback викликається синхронно замість асинхронно? Порушується передбачувана поведінка Event Loop.
  • Callback ніколи не викликається? Платіж зависає у невизначеному стані.
  • Callback викликається без параметрів або з некоректними типами? Код ламається у непередбачуваний спосіб.

Проблема полягає у тому, що ми не маємо жодних гарантій щодо поведінки сторонньої бібліотеки. Усю відповідальність за коректність асинхронного потоку виконання перекладено на розробників бібліотеки, у яких можуть бути баги або інше розуміння контракту callback.

Інверсія керування — це фундаментальна проблема довіри (trust issue). Передаючи callback, ви делегуєте критично важливу частину логіки застосунку коду, який ви не контролюєте. Проміси (Promises) вирішують цю проблему, повертаючи контроль розробнику через явний інтерфейс обробки результатів.

Рефакторинг Callback Hell: іменовані функції

Одним із способів пом'якшення Callback Hell є винесення вкладених callback у окремі іменовані функції. Перепишемо попередній приклад із читанням конфігурації, підключенням до бази даних та записом файлу:

import { readFile, writeFile } from 'node:fs';
import { Client } from 'pg';

function loadConfig(callback: (error: Error | null, config?: any) => void): void {
  readFile('./config.json', 'utf-8', (error, data) => {
    if (error) return callback(error);
    
    try {
      const config = JSON.parse(data);
      callback(null, config);
    } catch (parseError) {
      callback(parseError as Error);
    }
  });
}

function connectToDatabase(config: any, callback: (error: Error | null, client?: Client) => void): void {
  const client = new Client({
    host: config.database.host,
    port: config.database.port,
    database: config.database.name,
  });
  
  client.connect((error) => {
    if (error) return callback(error);
    callback(null, client);
  });
}

function fetchUser(client: Client, userId: number, callback: (error: Error | null, user?: any) => void): void {
  client.query('SELECT * FROM users WHERE id = $1', [userId], (error, result) => {
    if (error) return callback(error);
    callback(null, result.rows[0]);
  });
}

function saveUserToFile(user: any, callback: (error: Error | null) => void): void {
  const userData = JSON.stringify(user, null, 2);
  writeFile(`./user-${user.id}.json`, userData, 'utf-8', callback);
}

// Основна логіка
loadConfig((error, config) => {
  if (error) {
    console.error('Помилка завантаження конфігурації:', error);
    return;
  }
  
  connectToDatabase(config, (error, client) => {
    if (error) {
      console.error('Помилка підключення до БД:', error);
      return;
    }
    
    fetchUser(client!, 42, (error, user) => {
      if (error) {
        console.error('Помилка отримання користувача:', error);
        client!.end();
        return;
      }
      
      saveUserToFile(user, (error) => {
        if (error) {
          console.error('Помилка збереження файлу:', error);
        } else {
          console.log('Дані користувача збережено');
        }
        client!.end();
      });
    });
  });
});

Цей підхід покращує читабельність через виділення кожної операції у окрему функцію з описовою назвою. Проте проблема глибокої вкладеності залишається — ми просто перенесли складність у структуру викликів.

Бібліотеки на кшталт async.js пропонували утиліти для композиції callback-based операцій (async.waterfall, async.series, async.parallel), але навіть вони не вирішували фундаментальних проблем читабельності та обробки помилок. З появою промісів та async/await необхідність у таких бібліотеках практично зникла.

Обробка помилок у callback-based коді: обмеження та ризики

Одна з найбільших проблем callback — складність централізованої обробки помилок. У синхронному коді можна огорнути блок викликів у try/catch та обробити всі помилки в одному місці:

// Синхронний код — централізована обробка помилок
try {
  const config = JSON.parse(readFileSync('./config.json', 'utf-8'));
  const client = connectToDatabase(config);
  const user = fetchUser(client, 42);
  saveUserToFile(user);
  console.log('Успішно');
} catch (error) {
  console.error('Сталася помилка:', error);
  // Централізована логіка відкату (rollback), логування, сповіщення
}

У callback-based коді кожна асинхронна операція вимагає власної обробки помилок:

readFile('./config.json', 'utf-8', (error, data) => {
  if (error) {
    handleError(error); // Обробка помилки 1
    return;
  }
  
  connectToDatabase(config, (error, client) => {
    if (error) {
      handleError(error); // Обробка помилки 2
      return;
    }
    
    fetchUser(client, 42, (error, user) => {
      if (error) {
        handleError(error); // Обробка помилки 3
        client.end();
        return;
      }
      
      saveUserToFile(user, (error) => {
        if (error) {
          handleError(error); // Обробка помилки 4
        }
        client.end();
      });
    });
  });
});

Це призводить до кількох проблем:

  1. Дублювання коду обробки помилок: кожна гілка помилки виглядає майже ідентично.
  2. Складність управління ресурсами: потрібно пам'ятати закривати з'єднання до бази даних у кожній гілці помилки.
  3. Відсутність стека викликів: якщо помилка виникає глибоко у вкладених callback, стек викликів (call stack) може не містити корисної інформації про контекст, оскільки оригінальні функції вже завершилися.
Необроблені помилки у callback призводять до аварійного завершення процесу Node.js. Якщо callback викидає виключення, яке не перехоплено, процес отримує подію uncaughtException та за замовчуванням завершується з кодом помилки. Це критично для продакшн-серверів, де один необроблений виняток може покласти весь сервіс.

Реальний приклад: HTTP API з callback-based логікою

Розглянемо типовий HTTP API endpoint, написаний у callback-стилі, який:

  1. Приймає POST-запит з даними користувача.
  2. Перевіряє наявність користувача у базі даних.
  3. Якщо користувач існує — повертає помилку 409 Conflict.
  4. Якщо не існує — створює нового користувача та повертає його дані.
import { createServer } from 'node:http';
import { Client } from 'pg';

const dbClient = new Client({
  host: 'localhost',
  port: 5432,
  database: 'myapp',
  user: 'postgres',
  password: 'password',
});

dbClient.connect((error) => {
  if (error) {
    console.error('Не вдалося підключитися до БД:', error);
    process.exit(1);
  }
});

const server = createServer((req, res) => {
  if (req.method === 'POST' && req.url === '/api/users') {
    let body = '';
    
    req.on('data', (chunk) => {
      body += chunk.toString();
    });
    
    req.on('end', () => {
      let userData;
      
      try {
        userData = JSON.parse(body);
      } catch (parseError) {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'Invalid JSON' }));
        return;
      }
      
      // Перевірка існування користувача
      dbClient.query(
        'SELECT id FROM users WHERE email = $1',
        [userData.email],
        (error, result) => {
          if (error) {
            console.error('Помилка запиту до БД:', error);
            res.writeHead(500, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({ error: 'Database error' }));
            return;
          }
          
          if (result.rows.length > 0) {
            // Користувач вже існує
            res.writeHead(409, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({ error: 'User already exists' }));
            return;
          }
          
          // Створення нового користувача
          dbClient.query(
            'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, email, name, created_at',
            [userData.email, userData.name],
            (error, result) => {
              if (error) {
                console.error('Помилка створення користувача:', error);
                res.writeHead(500, { 'Content-Type': 'application/json' });
                res.end(JSON.stringify({ error: 'Failed to create user' }));
                return;
              }
              
              const newUser = result.rows[0];
              res.writeHead(201, { 'Content-Type': 'application/json' });
              res.end(JSON.stringify(newUser));
            }
          );
        }
      );
    });
  } else {
    res.writeHead(404);
    res.end('Not Found');
  }
});

server.listen(3000, () => {
  console.log('Сервер слухає на порту 3000');
});

Цей код демонструє типові проблеми callback-based архітектури:

  • 4 рівні вкладеності: req.on('end') → перша перевірка → друга перевірка → створення користувача.
  • Повторювана логіка відповідей: кожна гілка помилки формує HTTP-відповідь майже ідентичним чином.
  • Складність тестування: для юніт-тестування потрібно мокати не лише базу даних, але й структуру подій req.on.
Порівняйте цей код із версією на промісах або async/await, яка буде розглянута у наступних лекціях. Різниця у читабельності та підтримці буде разючою — складний callback-код перетворюється у лінійну послідовність операцій.

Чому індустрія відмовилася від callback на користь промісів

До 2015 року callback були стандартом де-факто для асинхронного програмування у Node.js. Проте накопичені проблеми призвели до активних пошуків альтернатив. Ключові причини переходу:

🔴 Проблеми callback

  • Callback Hell: глибока вкладеність робить код важкочитабельним.
  • Відсутність композиції: складно комбінувати кілька асинхронних операцій.
  • Інверсія керування: втрата контролю над викликом callback.
  • Дублювання обробки помилок: кожен рівень вимагає власної перевірки if (error).
  • Складність паралельного виконання: для запуску кількох операцій одночасно потрібні бібліотеки на кшталт async.js.

🟢 Переваги промісів

  • Лінійний синтаксис: ланцюжки .then() усувають вкладеність.
  • Централізована обробка помилок: один .catch() для всього ланцюжка.
  • Контроль виконання: проміс — це об'єкт, який можна передавати, комбінувати та трансформувати.
  • Вбудована підтримка паралелізму: Promise.all(), Promise.race().
  • Стандартизація: проміси є частиною ECMAScript, підтримуються нативно усіма сучасними середовищами.

У 2015 році специфікація ECMAScript 6 офіційно включила Promise у стандарт мови. Node.js почав поступово додавати Promise-based API до стандартних модулів (модуль fs/promises з'явився у версії 10.0.0 у 2018 році). З 2017 року синтаксис async/await (ECMAScript 2017) остаточно закріпив перехід індустрії від callback до сучасних асинхронних патернів.

Сучасне місце callback у Node.js екосистемі

Незважаючи на перехід індустрії до промісів та async/await, callback залишаються актуальними у певних сценаріях:

Подієві інтерфейси (EventEmitter)

Модуль events та його похідні (HTTP streams, TCP sockets, процеси) використовують callback для обробки подій:

import { EventEmitter } from 'node:events';

const emitter = new EventEmitter();

// Callback для події 'data'
emitter.on('data', (chunk: Buffer) => {
  console.log('Отримано дані:', chunk.toString());
});

// Callback для події 'error'
emitter.on('error', (error: Error) => {
  console.error('Сталася помилка:', error);
});

emitter.emit('data', Buffer.from('Hello'));

Стрімові API (Streams)

Робота зі стрімами природно використовує callback для обробки подій:

import { createReadStream } from 'node:fs';

const stream = createReadStream('./large-file.txt', { encoding: 'utf-8' });

stream.on('data', (chunk: string) => {
  console.log('Прочитано шматок:', chunk.length, 'байтів');
});

stream.on('end', () => {
  console.log('Читання завершено');
});

stream.on('error', (error: Error) => {
  console.error('Помилка читання:', error);
});

Низькорівневі бібліотеки

Деякі низькорівневі бібліотеки (наприклад, драйвери баз даних, нативні розширення) все ще використовують callback для максимальної продуктивності та контролю.

Навіть якщо бібліотека надає лише callback-based API, ви можете легко обгорнути її у проміс за допомогою вбудованої утиліти util.promisify або вручну. Це дозволяє використовувати сучасний синтаксис async/await навіть зі старими бібліотеками.

Приклад обгортання callback у проміс:

import { readFile as readFileCallback } from 'node:fs';
import { promisify } from 'node:util';

// Автоматичне перетворення callback-based функції у Promise-based
const readFile = promisify(readFileCallback);

// Тепер можна використовувати з async/await
async function loadConfig(): Promise<any> {
  const data = await readFile('./config.json', 'utf-8');
  return JSON.parse(data);
}

Практичні рекомендації при роботі з callback

Якщо ви працюєте зі старим кодом або бібліотеками, що використовують callback, дотримуйтесь цих правил:

Правило 1: Завжди дотримуйтесь Error-First Convention

Перший параметр callback має бути зарезервовано для помилки, навіть якщо операція ніколи не може завершитися з помилкою. Передавайте null як перший аргумент у разі успіху.

Правило 2: Використовуйте return callback(...) для раннього виходу

Одразу після виклику callback повертайте значення, щоб запобігти подальшому виконанню коду у некоректному стані.

Правило 3: Ніколи не викликайте callback більше одного разу

Забезпечте, щоб callback викликався рівно один раз на кожен шлях виконання. Багатократний виклик призводить до непередбачуваної поведінки.

Правило 4: Обробляйте виключення всередині callback

Виключення, що виникають всередині callback, не можуть бути перехоплені зовнішнім try/catch. Огортайте код у try/catch безпосередньо у callback.

Правило 5: Розгляньте міграцію на проміси

Для нового коду завжди використовуйте проміси або async/await. Для старого коду розгляньте можливість поступової міграції через обгортання у promisify.

Порівняльна таблиця: callback vs проміси vs async/await

Для наочності розглянемо одну і ту ж операцію у різних стилях:

import { readFile } from 'node:fs';

function loadAndParse(callback: (error: Error | null, data?: any) => void): void {
  readFile('./data.json', 'utf-8', (error, content) => {
    if (error) return callback(error);
    
    try {
      const data = JSON.parse(content);
      callback(null, data);
    } catch (parseError) {
      callback(parseError as Error);
    }
  });
}

// Використання
loadAndParse((error, data) => {
  if (error) {
    console.error('Помилка:', error);
    return;
  }
  console.log('Дані:', data);
});

Підсумок та закріплення матеріалу

У цій лекції ми детально розглянули callback як перший механізм асинхронності у Node.js:

📌 Error-First Callback

  • Стандарт Node.js: перший параметр — помилка, другий — результат.
  • Форсує явну обробку помилок на кожному рівні.
  • Не можна перехопити помилки через зовнішній try/catch.
  • Вимагає return після виклику callback для запобігання подальшого виконання.

🔴 Callback Hell

  • Глибока вкладеність призводить до піраміди коду.
  • Дублювання логіки обробки помилок.
  • Складність управління ресурсами (закриття з'єднань, файлів).
  • Рішення: іменовані функції, бібліотеки async.js (застарілі), міграція на проміси.

⚠️ Інверсія керування

  • Передача callback = передача контролю над виконанням.
  • Немає гарантій щодо кількості викликів, таймінгу, параметрів.
  • Проміси повертають контроль розробнику через явний інтерфейс.

🔄 Сучасний контекст

  • Callback залишаються у EventEmitter, Streams, низькорівневих API.
  • Для нового коду використовуйте проміси та async/await.
  • Старі callback-based API можна обгорнути через util.promisify.

У наступній лекції ми детально розглянемо проміси (Promises) — сучасний механізм асинхронності, який вирішує більшість проблем callback-based коду та є основою для async/await.

Copyright © 2026