Домашнє завдання #19: Типи для твого продукту
У кожного справжнього застосунку під капотом є доменна модель — набір типів, які описують, з чим він працює. Сьогодні ти напишеш її для свого продукту — того самого, що ти обрав у ДЗ #5 і для якого будував каталог у ДЗ #14.
Що ти вже маєш
- У ДЗ #17 ти створив клас
Userз ролями, у ДЗ #18 — типUser. Сьогодні він стає інтерфейсом — це та сама модель - У ДЗ #14 твій каталог вже працює на JS — тепер ти опишеш його дані типами
Вимоги нижче сформульовані генерично — під будь-яку тему. Приклади — на дефолтній фільмотеці FilmShelf: там сутності називаються Movie та WatchlistItem. У тебе вони називатимуться відповідно до твоєї теми (гра і запис в бібліотеці, рецепт і запис у кулінарній книзі, місто і запис у щоденнику подорожей...).
Файлова структура
hw-19/
├── tsconfig.json ← той самий strict-конфіг, що в hw-18
└── src/
├── types.ts ← всі інтерфейси та типи
├── storage.ts ← generic-сховище
└── demo.ts ← приклади використання
Перевірка: npx tsc --noEmit без помилок.
Завдання
1. Базові інтерфейси: Timestamps та User
Timestamps— спільний інтерфейс для всіх сутностей:readonly createdAt: DateтаupdatedAt: DateUser— розширюєTimestamps(extends) і містить:readonly id: number,name,email,role(literal union"user" | "moderator" | "admin"— ті ж ролі, що в ДЗ #17), та мінімум два optional-поля (наприкладavatar?,bio?)
2. Елемент каталогу
Головна сутність твоєї колекції (у FilmShelf — Movie). Вимоги:
- Розширює
Timestamps, маєreadonly id: numberтаtitle: string - Має поле-категорію з власним literal union type мінімум на 4 значення (у FilmShelf —
type Genre = "action" | "drama" | "comedy" | "sci-fi" | "horror"; у рецептів це можуть бути типи страв, в ігор — жанри) - Мінімум два специфічні поля твоєї теми (рік випуску, тривалість, автор, складність...)
- Optional-поле
tags?: string[]
3. Запис колекції користувача
Сутність, що зв'язує користувача з елементом каталогу (у FilmShelf — WatchlistItem: «Олексій додав "Дюну" і подивився її на 9/10»). Вимоги:
- Розширює
Timestamps, маєreadonly id: numberтаuserId: number - Поле
item— не вся сутність, а тільки потрібні поля черезPick(у FilmShelf —Pick<Movie, "id" | "title" | "genre">) - Поле статусу з literal union мінімум на 3 стани (у FilmShelf —
"planned" | "watching" | "watched"; назви — під твою тему) - Optional-поля: оцінка
rating?: numberта нотаткаnote?: string
4. CRUD-типи через utility types
Для кожної з трьох сутностей створи:
CreateX— тип для створення: безid,createdAt,updatedAt(вони генеруються автоматично). ВикористайOmitUpdateX— тип для оновлення: всі поля зCreateX, але optional. ВикористайPartial
Вручну поля не дублювати — тільки utility types: зміниться інтерфейс, зміняться й CRUD-типи.
5. Функції доменної логіки
Реалізуй чотири функції з такими сигнатурами (назви полів — під твою модель):
// Елементи каталогу однієї категорії
function filterByCategory(items: Movie[], category: Genre): Movie[]
// Публічний профіль — без email (використай Pick або Omit у return type)
function getPublicProfile(user: User): Pick<User, "id" | "name" | "role">
// Записи колекції, згруповані за статусом
function groupByStatus(records: WatchlistItem[]): Record<WatchStatus, WatchlistItem[]>
// Середня оцінка; null — якщо жодного запису з оцінкою
function averageRating(records: WatchlistItem[]): number | null
6. Generic-сховище
Опиши generic-інтерфейс сховища та реалізуй фабрику createStorage<T>():
interface Storage<T extends { id: number }> {
getAll(): T[];
getById(id: number): T | undefined;
create(item: Omit<T, "id" | "createdAt" | "updatedAt">): T;
update(id: number, updates: Partial<Omit<T, "id" | "createdAt">>): T | undefined;
delete(id: number): boolean;
find(predicate: (item: T) => boolean): T[];
}
createStorage<T>() повертає об'єкт, що реалізує Storage<T>, тримає елементи в масиві та автоматично генерує id, createdAt, updatedAt. Один generic — і сховище працює для всіх трьох сутностей.
Приклад використання (у demo.ts)
const movieStorage = createStorage<Movie>();
const watchlistStorage = createStorage<WatchlistItem>();
const dune = movieStorage.create({
title: "Дюна: Частина друга",
genre: "sci-fi",
year: 2024,
durationMin: 166,
});
console.log(dune.id); // 1 — згенеровано автоматично
console.log(dune.createdAt); // поточна дата
const record = watchlistStorage.create({
userId: 1,
item: { id: dune.id, title: dune.title, genre: dune.genre },
status: "watched",
rating: 9,
});
movieStorage.update(dune.id, { year: 2024 }); // оновлює і updatedAt
movieStorage.getById(999); // undefined
watchlistStorage.find((r) => r.status === "watched").length; // 1
averageRating([record]); // 9
averageRating([]); // null
// А це — помилки компіляції, і це добре:
movieStorage.create({ title: "Тест" }); // бракує обов'язкових полів
record.id = 5; // readonly!
record.status = "looking"; // не входить у literal union
Бонус
Validator<T>— інтерфейс з методомvalidate(data: unknown): data is T; реалізуй валідатор хоча б для однієї сутності (type guard)type DeepReadonly<T>— рекурсивний Readonly, який заморожує і вкладені об'єкти- Подія
onChangeу Storage — колбек, що викликається при create/update/delete (Observer з generics)
Підказки
Рівень 1: напрямок
Почни з types.ts і йди від простого до складного: Timestamps → User → елемент каталогу → запис колекції → CRUD-типи. Функції та сховище пиши тільки коли типи компілюються. Не бійся literal union-типів «під себе» — у цьому вся суть: твоя модель, твої назви.
Рівень 2: інструменти
interface B extends A — успадкування полів. Omit<T, "id" | "createdAt" | "updatedAt"> — тип без перелічених полів; Partial<T> — всі поля optional; Pick<T, "id" | "title"> — тільки перелічені. У groupByStatus стартовий об'єкт акумулятора зручно типізувати як Record<WatchStatus, WatchlistItem[]> з порожніми масивами для кожного статусу. Лічильник id — звичайна змінна в замиканні createStorage (привіт, ДЗ з closures).
Рівень 3: createStorage покроково
Усередині createStorage<T extends { id: number }>(): масив items: T[] = [] та let nextId = 1. У create — зібрати повний об'єкт: { ...item, id: nextId++, createdAt: new Date(), updatedAt: new Date() } і привести до T через as T (TypeScript не може довести, що Omit + додані поля = T — це відома межа дженериків). В update — знайти індекс, якщо -1 повернути undefined, інакше злити { ...items[i], ...updates, updatedAt: new Date() }. delete — filter або splice, повернути чи щось видалено.
Як здати
- Робота — у твоєму репозиторії курсу
my-web-course, у папціhw-19/ - У папці —
tsconfig.jsonтаREADME.md: тема твого продукту, як називаються сутності (що замість Movie/WatchlistItem) + команда перевірки npx tsc --noEmitпроходить без помилок- Мінімум 3 коміти з осмисленими повідомленнями — наприклад: інтерфейси сутностей, CRUD-типи та функції, generic storage
- Здай посиланням на папку
hw-19у репозиторії
Критерії оцінювання
| Критерій | Тип | Бали |
|---|---|---|
| Timestamps та User: extends, readonly, role як literal union, 2+ optional-поля | [код] | 15 |
| Елемент каталогу: власний literal union категорій (4+), специфічні поля теми | [код] | 15 |
| Запис колекції: Pick для item, literal union статусів (3+), optional rating/note | [код] | 10 |
| CRUD-типи через Omit/Partial для всіх трьох сутностей, без дублювання полів | [код] | 15 |
| Чотири функції: коректні типи та робоча логіка (перевіряється на прикладах вище) | [код] | 15 |
| Generic Storage + createStorage: всі 6 методів, авто id/createdAt/updatedAt | [код] | 20 |
| npx tsc --noEmit без помилок, strict: true, жодного any | [код] | 5 |
| Якість коду | [код] | 5 |
| Разом | 100 | |
| Бонус: Validator / DeepReadonly / onChange | [код] | +20 |
Що далі
Ці типи — не вправа в стіл. У ДЗ #20 вони запрацюють у твоєму каталозі, переписаному на TypeScript, а в ДЗ #25 — у повноцінному CRUD-застосунку на Next.js. Не видаляй цей код!