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
Sources