← From JavaScript to Fastify: one Task API, four levels
01 / 04 · 70 min

Modern JavaScript: the Task API starts with the domain

ES modules, immutable transformations, async/await and clear errors, on a framework-free task store.

Lesson content is currently available in Russian.

What you will learn

  • →Разделять доменную функцию и побочный эффект с помощью ES modules.
  • →Преобразовывать коллекции без случайной мутации исходного состояния.
  • →Правильно ожидать Promise, обрабатывать неуспешный HTTP-ответ и сохранять причину ошибки.
  • →Собрать тестируемое ядро Task API, которое станет основой следующих уроков.

Модуль — граница ответственности, а не папка

Начнём не с HTTP, а с правила, которое переживёт смену любого фреймворка: доменная логика не должна знать, откуда пришёл запрос и где лежат данные. ES modules дают явную границу через export/import. Функция создания задачи принимает данные, проверяет инварианты и возвращает новое значение. Она не читает глобальный массив и не отправляет ответ клиенту.

// task.js
export function createTask(input, now = () => new Date()) {
  const title = input.title?.trim();
  if (!title) throw new Error("TASK_TITLE_REQUIRED");

  return Object.freeze({
    id: crypto.randomUUID(),
    title,
    status: "todo",
    createdAt: now().toISOString(),
  });
}

// demo.js
import { createTask } from "./task.js";
console.log(createTask({ title: "  Проверить API  " }));

Инъекция now делает время управляемым в тесте. Object.freeze сигнализирует намерение, хотя это неглубокая заморозка. Частая ошибка новичка — спрятать внутри createTask запись в файл, fetch и генерацию ответа. Такая функция становится хрупкой: её нельзя проверить без окружения, а повторный вызов может дать неожиданный побочный эффект.

Коллекции: обновление без тихой мутации

Array.map, filter и find выражают намерение лучше ручного цикла, но важнее другое: новое состояние должно быть новым значением. Если изменить найденный объект на месте, все ссылки на него увидят изменение, а история, кэш и тесты станут недостоверными.

export function changeStatus(tasks, taskId, nextStatus) {
  const allowed = new Set(["todo", "doing", "done"]);
  if (!allowed.has(nextStatus)) throw new Error("TASK_STATUS_INVALID");

  let found = false;
  const updated = tasks.map((task) => {
    if (task.id !== taskId) return task;
    found = true;
    return { ...task, status: nextStatus };
  });

  if (!found) throw new Error("TASK_NOT_FOUND");
  return updated;
}

Функция имеет проверяемый контракт: исходный массив остаётся прежним; неизвестный id и статус дают разные коды ошибок. Антипаттерны здесь — sort без копии массива, splice внутри перебора и update через task.status = value. В backend это не академическая придирка: разделяемый объект может одновременно читать другой запрос.

Promise и async/await: ошибка должна дойти до границы

async-функция всегда возвращает Promise. await приостанавливает только текущую async-функцию, а не весь процесс. Главная практическая ловушка — забытый await или forEach с async callback: вызывающий код считает работу завершённой, пока операции ещё выполняются.

export async function loadTasks(fetchFn = fetch) {
  const response = await fetchFn("https://example.test/tasks");
  if (!response.ok) {
    throw new Error(`TASKS_HTTP_${response.status}`);
  }
  return response.json();
}

export async function notifyAll(tasks, notify) {
  await Promise.all(tasks.map((task) => notify(task)));
}

fetch не отклоняет Promise только потому, что сервер вернул 404 или 500, поэтому response.ok нужно проверять явно. Promise.all подходит, когда операции независимы и допустим параллельный запуск; обычный for...of с await — когда важны порядок или ограничение нагрузки. Не ловите ошибку только ради console.log: либо восстановитесь осмысленно, либо пробросьте её на HTTP-границу.

Первый вертикальный срез и проверяемый результат

Соберём минимальное in-memory хранилище. Оно намеренно не знает об HTTP. Методы возвращают данные или бросают доменные ошибки — именно этот контракт мы типизируем и подключим к Fastify далее.

import { createTask, changeStatus } from "./task.js";

export function createTaskStore(seed = []) {
  let tasks = [...seed];
  return {
    list: () => [...tasks],
    create(input) {
      const task = createTask(input);
      tasks = [...tasks, task];
      return task;
    },
    setStatus(id, status) {
      tasks = changeStatus(tasks, id, status);
      return tasks.find((task) => task.id === id);
    },
  };
}

Запустите файл через node demo.js с package.json, где type равен module. Проверяемый результат урока: create нормализует title, list не раскрывает внутренний массив, setStatus отличает неизвестную задачу от неверного статуса. Не добавляйте БД и сервер раньше времени: цель — сначала сделать маленькое ядро с ясным поведением.

Practice

Task Core v1

Создайте ES module с createTaskStore и добавьте методы get(id) и remove(id). Ни один метод не должен мутировать seed или возвращать внутренний массив по ссылке.

Deliverable

Репозиторий или patch с task.js, store.js и demo.js; демонстрация успешного create/update/remove и трёх отказов: пустой title, неверный status, неизвестный id.

Checkpoint

Докажите, что Task Core отделяет домен от эффектов и не теряет асинхронные ошибки.

  • ✓ Доменный модуль не импортирует HTTP-фреймворк, файловую систему или конкретную БД.
  • ✓ Обновление возвращает новый массив и новый изменённый объект, исходные данные остаются прежними.
  • ✓ Каждый Promise либо ожидается, либо явно возвращается вызывающему коду.
  • ✓ Неуспешный fetch проверяется через response.ok и превращается в осмысленную ошибку.

Check yourself

Почему createTask получает now аргументом?

01Почему createTask получает now аргументом?

Что делает fetch при HTTP 404?

02Что делает fetch при HTTP 404?

Как безопасно обновить один объект в массиве задач?

03Как безопасно обновить один объект в массиве задач?

Sources

Modern JavaScript: the Task API starts with the domain · From JavaScript to Fastify: one Task API, four levels · VORCL