Вивчай
Домашнє завдання #20 · TypeScript у DOM: рефакторинг застосунку, generic storage, typed events
100 балів+20 бонусintermediate

Домашнє завдання #20: Твій каталог на TypeScript

Фінал TypeScript-блоку. У ДЗ #14 ти побудував каталог своєї колекції на чистому JS — він працює, але одна одрукована назва поля, і все тихо ламається в рантаймі. Сьогодні ти зробиш те, що постійно роблять у реальних командах: рефакторинг робочого JS-проєкту на TypeScript. Функціональність та сама — але тепер компілятор стоїть на варті кожного поля.

Що ти вже маєш

  • ДЗ #14 — робочий каталог: додавання, зміна статусу, видалення, редагування, фільтри, лічильник, localStorage. Це твій вихідний код
  • ДЗ #19 — типи твоєї доменної моделі та generic-сховище. Сьогодні вони йдуть у бій

Приклади нижче — на дефолтній фільмотеці (елемент — фільм, статус — «переглянуто»). У тебе — твоя тема і твої назви.


Файлова структура

hw-20/
├── index.html        ← з hw-14, онови шлях до скрипта на dist/app.js
├── styles.css        ← з hw-14, без змін
├── tsconfig.json
├── package.json      ← scripts: "build": "tsc", "dev": "tsc --watch"
└── src/
    ├── types.ts      ← доменні типи (з hw-19) + типи стану
    ├── storage.ts    ← generic TypedStorage для localStorage
    ├── dom.ts        ← типізований доступ до елементів
    └── app.ts        ← логіка каталогу

Довідковий tsconfig.json (це конфіг, не рішення):

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "noEmitOnError": true
  },
  "include": ["src"]
}

Завдання

1. Типи (types.ts)

  • enum Filter для фільтрів списку — три значення: всі / активні / завершені (назви — під твою тему: у фільмотеки «переглянуті», у подорожей «відвідані»)
  • Інтерфейс елемента колекції — візьми модель з ДЗ #19 і адаптуй: readonly id: string, дати — як ISO-рядки, а не Date (localStorage зберігає лише текст — JSON.parse не поверне тобі об'єкт Date), статусне поле, optional-поля твоєї теми
  • interface AppState — все, що описує поточний стан: масив елементів та активний фільтр

2. Generic-сховище (storage.ts)

У ДЗ #19 твій createStorage<T>() тримав дані в масиві. Тепер той самий принцип — але дані живуть у localStorage. Створи клас з такими сигнатурами:

class TypedStorage<T> {
  constructor(key: string, defaultValue: T)
  get(): T                                   // прочитати; defaultValue, якщо пусто або зламаний JSON
  set(value: T): void                        // серіалізувати та зберегти
  update(updater: (current: T) => T): void   // прочитати → перетворити → зберегти
  clear(): void                              // видалити ключ
}

Використання: const itemsStorage = new TypedStorage<CollectionItem[]>("items", []) — і жодного «сирого» localStorage.getItem поза цим класом.

3. Типізований DOM (dom.ts)

  • Функція getElement<T extends HTMLElement>(selector: string): T — повертає елемент або кидає помилку з назвою селектора, якщо його немає. Більше ніяких мовчазних null
  • Всі елементи застосунку — з конкретними типами: HTMLFormElement, HTMLInputElement, HTMLButtonElement, HTMLUListElement... Просто HTMLElement — не зараховується

4. Логіка (app.ts)

Вся механіка твого каталогу з ДЗ #14 має працювати як раніше:

  • Додавання елемента (кнопка + Enter, порожній ввід ігнорується)
  • Перемикання статусу, видалення, редагування
  • Фільтри (через enum Filter), лічильник активних елементів
  • Збереження в localStorage через TypedStorage після кожної зміни, відновлення при завантаженні

Технічні вимоги до подій:

  • Обробники типізовані: SubmitEvent, MouseEvent, KeyboardEvent — не голий Event там, де відомо точніше
  • Делегування подій зі звуженням типу: event.target — це EventTarget | null, і компілятор змусить тебе чесно це обробити

5. Загальні вимоги типізації

  • strict: true, жодного any — ні явного, ні прихованого
  • Всі функції — з типізованими параметрами та return types
  • npx tsc --noEmit — нуль помилок

Сценарії перевірки

  1. Додаєш елемент → він у списку, лічильник +1, у localStorage з'явився запис
  2. Перезавантажуєш сторінку → всі елементи та їхні статуси на місці
  3. Перемикаєш фільтр «завершені» → видно лише завершені, лічильник не змінюється (він рахує активні)
  4. Редагуєш елемент, тиснеш Escape → зміни скасовано
  5. Псуєш вручну JSON у localStorage → застосунок не падає, стартує з порожнім списком

Бонус

  • Сортування списку (за датою / за назвою) через literal type SortBy — з збереженням вибору
  • Експорт/імпорт колекції в JSON-файл з runtime-валідацією через type guard (isCollectionItem)
  • Undo для видалення: тост «Елемент видалено — Повернути» на 5 секунд
  • Drag-and-drop порядок елементів з типізацією DragEvent

Підказки

Рівень 1: як підступитися до рефакторингу

Не переписуй з нуля — мігруй. Скопіюй script.js з hw-14 у src/app.ts, запусти npm run dev (tsc --watch) і сприймай список помилок компілятора як план роботи: кожна помилка — це місце, де JS дозволяв собі зайве. Винось типи в types.ts, роботу з localStorage — у storage.ts, пошук елементів — у dom.ts. Комить після кожного великого кроку.

Рівень 2: типові помилки міграції та інструменти

event.target має тип EventTarget | null — звужуй через instanceof HTMLElement або target.closest<HTMLLIElement>("[data-id]"). dataset.id — це string | undefined, перевір на undefined перед використанням. JSON.parse повертає any — саме тому весь парсинг живе всередині TypedStorage, а назовні виходить уже T. Для enum у розмітці зручно класти значення в data-filter і приводити: target.dataset.filter as Filter після перевірки.

Рівень 3: скелет міграції по кроках
  1. types.ts: enum Filter, інтерфейс елемента (id: string, дати як ISO-рядки), AppState
  2. storage.ts: у get() — try/catch навколо JSON.parse; у catch повертай defaultValue (це і є сценарій 5)
  3. dom.ts: const el = document.querySelector<T>(selector); if (!el) throw new Error(...); return el;
  4. app.ts: держи стан у змінній типу AppState; кожна операція = змінити стан → itemsStorage.set(...) → перерендерити
  5. Рендер: збирай елементи через document.createElement або template string; дата-атрибут з id — для делегування
  6. Наприкінці — пройди всі 5 сценаріїв руками та зроби скріншот

Як здати

  1. Робота — у твоєму репозиторії курсу my-web-course, у папці hw-20/
  2. У папці — tsconfig.json, package.json та README.md: 2-3 речення (тема каталогу, що змінилось порівняно з hw-14) + як зібрати й відкрити
  3. npx tsc --noEmit проходить без помилок
  4. Скріншот hw-20/screenshots/desktop.png (~1440px): працюючий каталог з кількома елементами у різних статусах
  5. Мінімум 3 коміти з осмисленими повідомленнями — наприклад: типи + tsconfig, storage + dom, міграція логіки
  6. Здай посиланням на папку hw-20 у репозиторії

Критерії оцінювання

КритерійТипБали
types.ts: enum Filter, інтерфейс елемента на основі hw-19 (ISO-дати, readonly id), AppState[код]15
TypedStorage: всі 4 методи, generic, стійкість до зламаного JSON[код]15
dom.ts: getElement з помилкою при відсутності, конкретні типи елементів[код]10
CRUD працює: додавання, toggle статусу, видалення, редагування (сценарії 1, 4)[поведінка]20
Фільтри через enum та лічильник активних (сценарій 3)[поведінка]10
localStorage: збереження та відновлення після перезавантаження (сценарії 2, 5)[поведінка]10
Типізовані події та делегування зі звуженням event.target[код]5
strict: true, жодного any, npx tsc --noEmit без помилок[код]5
desktop.png: працюючий каталог з елементами в різних статусах[скрін]5
Якість коду[код]5
Разом100
Бонус: сортування / export-import з type guard / undo / drag-and-drop[код]+20

Що далі

Твій каталог тепер типобезпечний — і готовий до наступного стрибка: у Block 8 він переїде на React, а в ДЗ #25 отримає справжній бекенд з CRUD API на Next.js — з цими ж типами з ДЗ #19. Не видаляй цей код!