Вивчай

TypeScript з DOM

TypeScript з DOM — охоронець перевіряє бейджі на вході в будівлю з лупоюTypeScript з DOM — охоронець перевіряє бейджі на вході в будівлю з лупою

У Block 5 ми активно працювали з DOM: шукали елементи, додавали обробники подій, маніпулювали класами та атрибутами. Тепер навчимось робити це type-safe -- з повною типізацією. TypeScript має вбудовані типи для всіх DOM-елементів, подій та API браузера.


HTML Element Types

TypeScript знає про всі HTML-елементи та їхні типи:

// querySelector повертає Element | null
const element = document.querySelector(".container");
// element: Element | null

// Щоб отримати конкретний тип елемента:
const input = document.querySelector("input");
// input: HTMLInputElement | null

const button = document.querySelector("button");
// button: HTMLButtonElement | null

const link = document.querySelector("a");
// link: HTMLAnchorElement | null

Основні типи елементів

HTML тегTypeScript тип
<div>, <span>, <p>HTMLDivElement, HTMLSpanElement, HTMLParagraphElement
<input>HTMLInputElement
<button>HTMLButtonElement
<a>HTMLAnchorElement
<form>HTMLFormElement
<img>HTMLImageElement
<select>HTMLSelectElement
<textarea>HTMLTextAreaElement
<canvas>HTMLCanvasElement
Будь-який елементHTMLElement

Проблема з null

querySelector завжди повертає Element | null, бо елемент може не існувати на сторінці. TypeScript змушує тебе обробити цей випадок:

const title = document.querySelector("h1");
// title: HTMLHeadingElement | null

// console.log(title.textContent); // Помилка: 'title' is possibly 'null'

// Рішення 1: перевірка на null (рекомендовано)
if (title) {
  console.log(title.textContent); // OK
}

// Рішення 2: optional chaining
console.log(title?.textContent); // string | undefined

// Рішення 3: early return
function updateTitle(text: string): void {
  const title = document.querySelector("h1");
  if (!title) return; // early return якщо немає елемента

  title.textContent = text; // TypeScript знає: title не null
}

Type Assertions -- твердження про тип

Type assertion каже TypeScript: "Я знаю тип краще за тебе":

// querySelector з класом повертає Element | null
const input = document.querySelector(".email-input");
// input: Element | null

// Type assertion -- ми знаємо, що це HTMLInputElement
const emailInput = document.querySelector(".email-input") as HTMLInputElement;
// emailInput: HTMLInputElement

// Тепер маємо доступ до .value
console.log(emailInput.value);
Увага

Type assertion не перевіряє тип під час виконання! Якщо елемент насправді не HTMLInputElement, TypeScript не покаже помилку -- помилка буде в рантаймі. Використовуй assertions тільки коли впевнений у типі.

Assertion з перевіркою (безпечніший варіант)

const element = document.querySelector(".email-input");

if (element instanceof HTMLInputElement) {
  // TypeScript автоматично звузив тип -- assertion не потрібен!
  console.log(element.value);
}

Non-null Assertion (!)

Оператор ! каже TypeScript "це точно не null/undefined":

// Ми знаємо, що елемент існує (наприклад, він завжди є в HTML)
const app = document.getElementById("app")!;
// app: HTMLElement (без null)

// Без ! було б: HTMLElement | null
Увага

Використовуй ! дуже обережно. Якщо елемент все ж виявиться null, отримаєш runtime error. Краще використовувати if перевірку або optional chaining.


Type Guards -- захисники типів

Вбудовані type guards

Ми вже використовували typeof, instanceof та in для звуження типів (урок 7.2). З DOM вони особливо корисні:

function handleElement(element: HTMLElement): void {
  if (element instanceof HTMLInputElement) {
    console.log("Input value:", element.value);
  } else if (element instanceof HTMLAnchorElement) {
    console.log("Link href:", element.href);
  } else if (element instanceof HTMLImageElement) {
    console.log("Image src:", element.src);
  } else {
    console.log("Text:", element.textContent);
  }
}

Custom Type Guards -- користувацькі type guards

Можна створити свою функцію-guard з предикатом is:

interface User {
  type: "user";
  name: string;
  email: string;
}

interface Admin {
  type: "admin";
  name: string;
  email: string;
  permissions: string[];
}

type Person = User | Admin;

// Custom type guard -- зверни увагу на return type: person is Admin
function isAdmin(person: Person): person is Admin {
  return person.type === "admin";
}

function greet(person: Person): string {
  if (isAdmin(person)) {
    // TypeScript знає: person -- це Admin
    return `Привіт, адмін ${person.name}! Дозволи: ${person.permissions.join(", ")}`;
  }
  // TypeScript знає: person -- це User
  return `Привіт, ${person.name}!`;
}

Type guard для DOM

function isInputElement(element: Element): element is HTMLInputElement {
  return element.tagName === "INPUT";
}

function isFormElement(element: Element): element is HTMLFormElement {
  return element.tagName === "FORM";
}

// Використання
const elements = document.querySelectorAll("*");
elements.forEach((el) => {
  if (isInputElement(el)) {
    console.log("Input:", el.value); // el: HTMLInputElement
  }
});

Типізація подій

TypeScript знає типи для всіх DOM-подій:

// Click event
const button = document.querySelector("button");
if (button) {
  button.addEventListener("click", (event: MouseEvent) => {
    console.log(`Клік на координатах: ${event.clientX}, ${event.clientY}`);
    // event.target: EventTarget | null
  });
}

// Input event
const input = document.querySelector("input");
if (input) {
  input.addEventListener("input", (event: Event) => {
    const target = event.target as HTMLInputElement;
    console.log("Значення:", target.value);
  });
}

// Keyboard event
document.addEventListener("keydown", (event: KeyboardEvent) => {
  console.log(`Натиснуто: ${event.key}`);
  if (event.key === "Escape") {
    // закрити модалку
  }
});

// Submit event
const form = document.querySelector("form");
if (form) {
  form.addEventListener("submit", (event: SubmitEvent) => {
    event.preventDefault();
    const formData = new FormData(event.target as HTMLFormElement);
    console.log("Name:", formData.get("name"));
  });
}

Основні типи подій

ПодіяTypeScript тип
click, dblclick, mousedown, mouseupMouseEvent
keydown, keyup, keypressKeyboardEvent
input, changeEvent
submitSubmitEvent
focus, blurFocusEvent
drag, dropDragEvent
scroll, resizeEvent

Типізація Fetch API

Один з найважливіших сценаріїв -- типізація даних з API:

// Інтерфейс для даних з API
interface ApiUser {
  id: number;
  name: string;
  email: string;
  phone: string;
}

// Простий fetch з типізацією
async function fetchUser(id: number): Promise<ApiUser> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);

  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  const data: ApiUser = await response.json();
  return data;
}

// Використання
async function main(): Promise<void> {
  const user = await fetchUser(1);
  console.log(user.name);  // TypeScript знає -- це string
  console.log(user.email); // string
}

Generic fetch helper

async function fetchJson<T>(url: string): Promise<T> {
  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  return response.json() as Promise<T>;
}

// Використання -- тип передається як generic
interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

const post = await fetchJson<Post>(
  "https://jsonplaceholder.typicode.com/posts/1"
);
// post: Post

const posts = await fetchJson<Post[]>(
  "https://jsonplaceholder.typicode.com/posts"
);
// posts: Post[]
Увага

response.json() повертає Promise<any>. Type assertion (as Promise<T>) не перевіряє дані під час виконання -- якщо API поверне інший формат, TypeScript не допоможе. У реальних проєктах використовують бібліотеки валідації (Zod, io-ts) для runtime-перевірки.

Типізація POST-запиту

interface CreatePostData {
  title: string;
  body: string;
  userId: number;
}

interface CreatePostResponse {
  id: number;
  title: string;
  body: string;
  userId: number;
}

async function createPost(data: CreatePostData): Promise<CreatePostResponse> {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  return response.json() as Promise<CreatePostResponse>;
}

// Використання
const newPost = await createPost({
  title: "Мій пост",
  body: "Зміст поста",
  userId: 1,
});

console.log(`Створено пост #${newPost.id}`);

Практика: типізований Todo-додаток (фрагмент)

// Типи
interface Todo {
  id: number;
  text: string;
  completed: boolean;
  createdAt: Date;
}

type TodoFilter = "all" | "active" | "completed";

// DOM-елементи з типами
function initApp(): void {
  const form = document.querySelector<HTMLFormElement>("#todo-form");
  const input = document.querySelector<HTMLInputElement>("#todo-input");
  const list = document.querySelector<HTMLUListElement>("#todo-list");
  const filterBtns = document.querySelectorAll<HTMLButtonElement>(".filter-btn");

  if (!form || !input || !list) {
    throw new Error("Required DOM elements not found");
  }

  let todos: Todo[] = [];
  let currentFilter: TodoFilter = "all";

  // Type-safe event handler
  form.addEventListener("submit", (event: SubmitEvent) => {
    event.preventDefault();
    const text = input.value.trim();
    if (!text) return;

    const newTodo: Todo = {
      id: Date.now(),
      text,
      completed: false,
      createdAt: new Date(),
    };

    todos.push(newTodo);
    input.value = "";
    renderTodos();
  });

  // Фільтрація
  function getFilteredTodos(): Todo[] {
    switch (currentFilter) {
      case "active":
        return todos.filter((t) => !t.completed);
      case "completed":
        return todos.filter((t) => t.completed);
      default:
        return todos;
    }
  }

  function renderTodos(): void {
    const filtered = getFilteredTodos();
    list!.innerHTML = filtered
      .map(
        (todo) => `
        <li data-id="${todo.id}" class="${todo.completed ? "completed" : ""}">
          ${todo.text}
        </li>
      `
      )
      .join("");
  }
}

initApp();
Порада

Зверни увагу на document.querySelector<HTMLFormElement>(...) -- це generic-виклик querySelector, який одразу задає правильний тип повернення. Не потрібен as assertion!


Підсумок

  • TypeScript має типи для всіх DOM-елементів: HTMLInputElement, HTMLButtonElement тощо
  • querySelector повертає Element | null -- завжди перевіряй на null
  • Type assertions (as) -- ми кажемо TypeScript, який тип очікуємо (без runtime-перевірки)
  • Non-null assertion (!) -- ми впевнені, що значення не null (обережно!)
  • Custom type guards (is) -- функції для звуження типу
  • DOM події типізовані: MouseEvent, KeyboardEvent, SubmitEvent
  • Fetch типізується через generics або assertions
  • querySelector<T>() -- generic версія, яка повертає правильний тип

Що далі?

Блок TypeScript завершено! Ти тепер знаєш основи типізації: базові типи, union/literal types, інтерфейси, generics, utility types та роботу з DOM. У наступному блоці -- Block 8: React -- ти будеш писати React-компоненти з TypeScript. Всі ці знання стануть фундаментом для створення типізованих компонентів, хуків та API-запитів.

Інфо

Корисні посилання: