Angular IDPStrong Junior
🔥 0

Модуль 2 · урок 13 з 15

Моделювання даних

Типи — це не бюрократія навколо коду, а спосіб проєктування. Добре описана модель робить неправильні стани невимовними: помилку не треба ловити тестом, її просто неможливо написати. Цей урок про те, як цим користуватись.

DTO і доменна модель — це різні речі

// те, що реально приходить із бекенду
export interface TaskDto {
  id: string;
  due_date: string | null;      // snake_case, дата рядком
  assignee_id: string | null;
  estimate_hours: number;
}

// те, чим зручно користуватись у застосунку
export interface Task {
  id: string;
  dueDate: Date | null;
  assigneeId: string | null;
  estimateHours: number;
}

// місток між ними — в одному місці
export function toTask(dto: TaskDto): Task {
  return {
    id: dto.id,
    dueDate: dto.due_date ? new Date(dto.due_date) : null,
    assigneeId: dto.assignee_id,
    estimateHours: dto.estimate_hours,
  };
}
Навіщо зайвий шар

Коли бекенд перейменує поле або поміняє формат дати, ти правиш одну функцію, а не сорок компонентів. Це та сама ізоляція, про яку йдеться в модулі 4: мок-сервіс віддає такий самий Task, тож потім реальний API підключається без переписування UI.

Літеральні типи замість магічних рядків

// ❌ так помилку видно тільки в проді
interface Task { status: string; }
task.status = 'complete';   // хтось напише так, хтось 'completed', хтось 'done'

// ✅ так вона не компілюється
export type TaskStatus = 'todo' | 'in_progress' | 'blocked' | 'done';
interface Task { status: TaskStatus; }
task.status = 'complete';   // ❌ помилка одразу
// список для випадайки — виводиться з типу, а не дублюється
export const TASK_STATUSES = ['todo', 'in_progress', 'blocked', 'done'] as const;
export type TaskStatus = typeof TASK_STATUSES[number];

as const каже TypeScript: «це не просто масив рядків, це саме ці чотири значення». Далі тип виводиться з масиву — і додати новий статус можна в одному місці.

Розрізнюваний union: неможливий стан стає невимовним

Класична проблема: компонент має три поля — loading, data, error. Скільки комбінацій вони дають? Вісім. Скільки з них осмислені? Три.

// ❌ можна описати стан, якого не буває
interface State {
  loading: boolean;
  data: Task[] | null;
  error: string | null;
}
const bad: State = { loading: true, data: [...], error: 'ой' };  // і що це означає?

// ✅ рівно три можливі стани
type State =
  | { status: 'loading' }
  | { status: 'ready'; data: Task[] }
  | { status: 'error'; message: string };
function render(state: State) {
  switch (state.status) {
    case 'loading': return spinner();
    case 'ready':   return table(state.data);      // TS знає, що data тут є
    case 'error':   return message(state.message); // а тут є message
  }
}
ТРИ ОКРЕМІ ПОЛЯ loading · data · error 8 можливих комбінацій осмислених — 3 решту 5 доводиться «не допускати» руками РОЗРІЗНЮВАНИЙ UNION { status: 'loading' } { status: 'ready', data } { status: 'error', message } рівно 3 стани, четвертий не описати Бонус: у кожній гілці switch доступні саме ті поля, які там існують — не треба ні перевірок на null, ні знаків оклику.
Замість того, щоб перевіряти неможливі комбінації, простіше зробити їх такими, що не описуються типом.

Звуження типів і type guards

// вбудовані способи звуження
if (typeof value === 'string') { … }
if (Array.isArray(value)) { … }
if (value instanceof Date) { … }
if ('data' in state) { … }
if (task.assigneeId !== null) { … }

// власний type guard: функція, яка ДОВОДИТЬ тип
export function isTask(value: unknown): value is Task {
  return typeof value === 'object'
    && value !== null
    && 'id' in value
    && typeof (value as { id: unknown }).id === 'string';
}

const raw: unknown = JSON.parse(text);
if (isTask(raw)) {
  raw.id;   // TypeScript тепер знає, що це Task
}

Запис value is Task — це обіцянка компілятору: «якщо функція повернула true, значення точно такого типу». Саме так виглядає чесна альтернатива as any для даних, у яких ти не впевнений.

Перевірка повноти switch

function label(status: TaskStatus): string {
  switch (status) {
    case 'todo':        return 'До роботи';
    case 'in_progress': return 'В роботі';
    case 'blocked':     return 'Заблоковано';
    case 'done':        return 'Готово';
    default: {
      const never: never = status;   // ❗ якщо додати новий статус — тут буде помилка
      return never;
    }
  }
}

Тип never означає «сюди неможливо потрапити». Якщо в union додасться пʼятий варіант, компілятор скаже, що його не можна присвоїти never — і покаже саме це місце. Прийом дешевий, а рятує від забутих гілок у десятках місць.

Правило, яке варто запамʼятати

Роби неправильні стани невимовними. Замість того, щоб перевіряти в рантаймі, що «якщо loading, то data має бути null» — опиши тип так, щоб інакше й не вийшло. Це головна ідея всього уроку, і саме її варто сказати ліду, якщо мова зайде про типізацію.

≈ 45 хв · +25 XP за урок