Вивчай
Домашнє завдання #19 · Interfaces, optional/readonly, extends, utility types, generics
100 балів+20 бонусintermediate

Домашнє завдання #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: Date
  • Userрозширює 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 (вони генеруються автоматично). Використай Omit
  • UpdateX — тип для оновлення: всі поля з 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() }. deletefilter або splice, повернути чи щось видалено.


Як здати

  1. Робота — у твоєму репозиторії курсу my-web-course, у папці hw-19/
  2. У папці — tsconfig.json та README.md: тема твого продукту, як називаються сутності (що замість Movie/WatchlistItem) + команда перевірки
  3. npx tsc --noEmit проходить без помилок
  4. Мінімум 3 коміти з осмисленими повідомленнями — наприклад: інтерфейси сутностей, CRUD-типи та функції, generic storage
  5. Здай посиланням на папку 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. Не видаляй цей код!

Пов'язані уроки