#6 Розширені типи в TypeScript - Union, Intersection, літерали, строгі типи

Окрім основних типів, які ми розглядали в перших уроках, у TypeScript э ще деякі додаткові можливості для роботи з типами. У цьому уроці ми розглянемо union, Intersection, Literal types, Type Assertions.

Union Types

Union дозволяє змінній приймати декілька можливих типів.

let value: string | number;

value = "Привіт"; 
console.log(value.toUpperCase()); // ПРИВІТ

value = 42; 
console.log(value.toFixed(2)); // 42.00

// ❌ Помилка: value = true; // Type 'boolean' is not assignable to type 'string | number'.

Що тут відбувається? TypeScript автоматично перевіряє, які методи доступні для конкретного типу (toUpperCase() працює для рядка, а toFixed(2) для числа). Це як приклад з length, з поперенього уроку. Де довелося робити костиль, у вигляді перетворення типу.

Я розумію, що я раніше вже казав про Union, але це потрібно нагадати, щоб ви зрозуміли тему далі.

Перетин типів (Intersection Types)

Intersection) створює новий тип, який об’єднує всі властивості декількох типів. Якщо простою мовою, то ви можете створити тип, тим самим обʼєднавши 2 інтерфейси, це дуже схожу на interface extends, але працюэ інашке.

interface Person {
    name: string;
    age: number;
}

interface Employee {
    company: string;
    position: string;
}

type wdr = Person & Employee;

const worker: wdr = {
    name: "Олексій",
    age: 30,
    company: "Tech Corp",
    position: "Розробник"
};

console.log(worker); 

Для нотатки ще скажу, що на відміну від JS, в TS є багато зарезервованих типів. Наприклад, якщо ви будете намагатися створити type Worker = Person & Employee, то ви отримаєте помилку, шо тип Worker вже зареестрований. 

І тут нема нічого такого. Ви просто повинні памʼятати, що TS розробляла Microsoft, тому вони за вас зареестрували, пару тисяч слів, які ви не зможете використовувати в назвах будь чого :)

Ще доречі ви повинні памʼятати, що якщо ви обʼєднуєте 2 типи, то властивості обох повинні знаходитися в кінцевому обʼєкті. Але ж ви памʼятаєте про оператор ? - name?: 'Олексій', який ми можем використовувати для таких випадків.

Literal Types

Про літерали я теж вже казав, але я нагадаю, що їх ще можна використовувати в функціях, та в інших потрібних місцях:

let direction: "up" | "down" | "left" | "right";

direction = "up"; // ✅ Коректно
direction = "down"; // ✅ Коректно

// ❌ Помилка: direction = "forward"; // Type '"forward"' is not assignable

function move(direction: "up" | "down" | "left" | "right") {
    console.log(`Рухаємось ${direction}`);
}

move("up"); // ✅ Рухаємось up
move("left"); // ✅ Рухаємось left

// ❌ Помилка: move("forward"); // Argument of type '"forward"' is not assignable

Як я казав раніше, вони визначають які данні, а не тип, може містити змінна.

Type Assertions

Приведення типів - це одна з самих користних речей в TS. Вона говорить TS, як використовувати іншу змінну:

let someValue: unknown = "Це рядок";
let strLength: number = (someValue as string).length;

console.log(strLength); // 10

Простий приклад, someValue unkown, тобто з невідомим типом. У невідомого типа не може бути length, тому що це може бути не строка, а число наприклад. Тому ви кажете TS, що точно знаєте, що someValue це строка. Другими словами (someValue as string) - на льоту назначає змінній новий тип. Але по факту використовуючи такі оператори, ви руйнуєте логіку TS, тому що якщо в someValue задати any, та число 25. То length, при as String видасть вже помилку JavaScript, яку не побачить TS. Ще є застарілий варіант написання такого коду, але його не рекомендовано використовувати. Показую для того, щоб ви не лякалися, коли у когось це побачите:

let someValue: unknown = "Це рядок";
let strLength: number = (<string>someValue).length;

І ще покажу приклад з DOM, тому що HTML елементи це окрема тема, і ми ще про неї не говорили, але ви теж ось таке можете зустріти

const input = document.getElementById("user-input") as HTMLInputElement;
input.value = "TypeScript працює!";

TypeScript містить типи для всіх стандартних DOM HTML елементів. Їх не обовʼязково всі знати, ви просто можете їх писати по назві тегу:

<input>	    HTMLInputElement
<button>	HTMLButtonElement
<div>	    HTMLDivElement
<a>	        HTMLAnchorElement
<form>	    HTMLFormElement
<img>	    HTMLImageElement

Такі типи потрібні для конкретної взаємодії з тегами. Наприклад у input є value, а у img його нема. Тобто, якщо ви вказали тип img, але намагаєтеся по прототипу задати value, то у вас в TS це не вийде.

Також для деяких випадків ви можете робити перевірки, які працюють, як зі звичайними типами:

const input = document.getElementById("username");

if (input instanceof HTMLInputElement) {
    input.value = "TypeScript працює!";
}

Робота з querySelector

Повертаючись до теми вище, querySelector може повертати будь-який CSS-селектор, тому потрібно явно вказувати тип.

const passwordInput = document.querySelector("input[type='password']") as HTMLInputElement;
passwordInput.value = "123456";

// або

const passwordInput = document.querySelector("input[type='password']");
if (passwordInput instanceof HTMLInputElement) {
    passwordInput.value = "123456";
}

Біль наглядно, чому це необхідно видно на прикладі event target

// img#username

document.getElementById("username")?.addEventListener("input", (event) => {
    const target = event.target as HTMLInputElement;
    console.log(event.target.value); // ❌ Помилка: Property 'value' does not exist on type 'EventTarget'.
});

Перетворення об’єкта

Повертаючись до extends та обʼєднання типів, це ще один варіант

type User = { name: string; age: number };
let person: unknown = { name: "Анна", age: 25 };

// Перетворення `unknown` у `User`
let user = person as User;
console.log(user.name); // ✅ Анна

Навіщо перетворення потрібні, скоріш за все буде більше зрозуміло, коли ви це випробуєте в реальних умовах, наприклад при поверненні данних з серверу:

async function fetchData() {
    let response: unknown = await fetch("https://api.example.com/user");
    let user = response as { id: number; name: string };
    console.log(user.name); // ❌ МОЖЕ БУТИ ПОМИЛКА, якщо `response` не є об'єктом
}

Але треба зрозуміти, що перевіряти данні з беку це містика, тому яб не заточував на цьому увагу. Але якщо ви все таки вирішили використовувати типи для async операцій, я б рекомендував робити додаткові перевірки:

if (typeof response === "object" && response !== null && "name" in response) {
    let user = response as { id: number; name: string };
    console.log(user.name);
}

Треба ще відмітити, що перетворення типів не змінює значення. Тобто якщо ви перетворили числову змінну на строку, значення залишиться числове. 

let num = "123" as unknown as number;
console.log(num + 10); // "12310" (НЕ 133, бо значення не змінилося!)

Тобто як я казав раніше, TS в багатьох випадках тільки додає проблеми, а не вирішує їх. Я його вчив колись по приколу, але потім він став необхідністю в багатьох компаніях. І хоч я написав на TS сотні проектів, але в своїх стартапах, я ніколи його не використовую.

keyof

Під завершення ще хотів би розказати про keyof. Його дуже рідко використовують, скоріш за все ви його ніколи не зустрінете. Але треба про нього розказати.

Оператор keyof отримує всі ключі об’єкта у вигляді літерального типу.

interface User {
    id: number;
    name: string;
    email: string;
}

type UserKeys = keyof User; // "id" | "name" | "email"

let key: UserKeys = "name"; // ✅ Коректно
// ❌ Помилка: key = "password"; // Property 'password' does not exist on type 'UserKeys'

Тобто ви створюєте тип із інтерфейса. Мабуть ви хочете спитати навіщо це потрібно? Я теж саме питання хочу задати розробникам TS, тільки не про keyof, а про TS.