Skip to main content

Звуження типів у TypeScript

Звуження типів (type narrowing) - це те, як TypeScript уточнює змінну з широкого union-типу до конкретного підтипу, аналізуючи перевірки, які ти вже пишеш у коді.

Теорія

TL;DR

  • Уяви фейсконтроль: параметр string | number стає просто string, коли ти довів це через typeof
  • TypeScript відстежує потік управління через if/else, switch і ранні return, звужуючи типи в кожній гілці
  • Шість основних технік: typeof, instanceof, in, перевірка рівності, перевірка правдивості, предикати типу (value is Type)
  • Дискриміновані union-и (спільне літеральне поле kind або status) - найчистіший патерн для складних станів
  • Все звуження відбувається на етапі компіляції. Нульова вартість у рантаймі, перевірки виконуються як звичайний JavaScript

Швидкий приклад

typescript
function logValue(input: string | number) { // input: string | number тут if (typeof input === "string") { // TypeScript звужує: input - це string input.toUpperCase(); // OK } else { // input: number input.toFixed(2); // OK } }

TypeScript бачить typeof input === "string" і перетинає union з string у цій гілці. Гілка else отримує те, що залишилось: number. Наведи курсор на input в IDE всередині кожної гілки - тип змінюється.

Як компілятор звужує типи

Компілятор TypeScript запускає аналіз потоку управління на AST. Для кожної гілки (if, else, switch, ранній return) він перераховує, яким може бути тип змінної в цьому місці. Оригінальний тип не змінюється. TypeScript просто знає про нього більше всередині поточного scope.

Жодних витрат у рантаймі. Все стирається при компіляції. V8 бачить звичайні JavaScript-умови, нічого особливого.

Шість технік звуження

typeof для union-ів з примітивами:

typescript
function format(val: string | number | null) { if (val === null) return "N/A"; if (typeof val === "string") return val.trim(); return val.toLocaleString(); // val: number тут }

instanceof для екземплярів класів:

typescript
function handleEvent(event: MouseEvent | KeyboardEvent) { if (event instanceof MouseEvent) { console.log(event.clientX); // event: MouseEvent } else { console.log(event.key); // event: KeyboardEvent } }

Оператор in для форм об'єктів та інтерфейсів:

typescript
interface Circle { radius: number; } interface Square { size: number; } function getArea(shape: Circle | Square) { if ("radius" in shape) { return Math.PI * shape.radius ** 2; // shape: Circle } return shape.size ** 2; // shape: Square }

Звуження через рівність для дискримінованих union-ів:

typescript
type Result = | { status: "ok"; data: string } | { status: "err"; message: string }; function handle(result: Result) { if (result.status === "ok") { console.log(result.data); // result: { status: "ok"; data: string } } else { console.log(result.message); } }

Звуження за правдивістю видаляє null/undefined:

typescript
function greet(name: string | null) { if (name) { console.log(name.toUpperCase()); // name: string } }

Предикати типу (type predicates) для повторно використовуваних guard-ів:

typescript
function isString(val: unknown): val is string { return typeof val === "string"; } if (isString(input)) input.toUpperCase(); // input: string

Коли що використовувати

  • Union з примітивами (string | number) → typeof
  • Екземпляри класів → instanceof
  • Форми об'єктів та інтерфейсів → "prop" in obj
  • Стейт-машини, відповіді API, Redux-екшени → дискриміновані union-и з перевіркою рівності
  • Складна валідація, невідомі дані з API → предикати типу
  • Перевірки на null/undefined → краще === null, ніж правдивість, для точності

Типові помилки

Використання == null замість === null:

typescript
// Неправильно - TypeScript НЕ звужує тут function bad(value: string | null) { if (value == null) return; value.length; // Помилка: value все ще string | null } // Правильно function good(value: string | null) { if (value === null) return; value.length; // value: string }

Либке == null відловлює і null, і undefined, тому TypeScript не може впевнено звузити string | null до string. Потрібне строге ===.

Звернення до пропси без in:

typescript
interface A { x: number; } interface B { y: string; } // Неправильно - TypeScript помилиться: x не існує в B function wrong(p: A | B) { if (p.x) { ... } } // Правильно function correct(p: A | B) { if ("x" in p) { /* p: A */ } }

Очікування, що звуження збережеться в колбеку:

typescript
function process(value: string | null) { if (value !== null) { setTimeout(() => { // TypeScript може позначити помилку: value могло змінитись console.log(value.toUpperCase()); }, 1000); } value = null; // змінено після перевірки }

Збережи звужене значення в const перед колбеком:

typescript
const v = value; // v: string setTimeout(() => console.log(v.toUpperCase()), 1000);

instanceof для примітивів:

typescript
// Неправильно - примітиви не є екземплярами класів if (value instanceof String) { ... } // Правильно if (typeof value === "string") { ... }

Забути перевірити опціональне поле після звуження union-у:

typescript
type Action = { type: "LOAD"; data?: string } | { type: "UPDATE" }; function handle(action: Action) { if (action.type === "LOAD") { // data все ще string | undefined тут! // action.data.trim(); // Помилка if (action.data) { action.data.trim(); // OK: string } } }

TypeScript звужує union, але не звужує автоматично вкладені опціональні поля. Кожен рівень потребує окремої перевірки.

Де зустрічається в реальному коді

  • React: дискриміновані пропси (if (props.kind === "primary") у поліморфних компонентах типу Chakra UI)
  • Redux Toolkit: звуження екшенів у редюсерах через switch (action.type)
  • tRPC/Express middleware: if ("userId" in req.body) для варіантів тіла запиту
  • Zod: if (result.success) { const data = result.data; } після .safeParse()
  • API-відповіді: type ApiResponse<T> = { success: true; data: T } | { success: false; error: string }

Питання на співбесіді

Q: Яка різниця між звуженням типів і type assertion (as string)?
A: Звуження доводить тип через аналіз потоку управління. TypeScript перевіряє твою логіку і звужує лише тоді, коли може підтвердити коректність. Assertion - це обіцянка компілятору без жодного доказу. Якщо обіцянка хибна, отримаєш помилку в рантаймі.

Q: Напиши type guard-функцію для об'єкта користувача з API.
A:

typescript
function isUser(obj: unknown): obj is { name: string; email: string } { return ( typeof obj === "object" && obj !== null && "name" in obj && "email" in obj ); }

Q: Чому TypeScript може не звузити union об'єктів, якщо спільна пропса має тип string замість літерала?
A: Якщо дискримінант - це string, а не "loading" | "success" | "error", TypeScript не може розрізнити варіанти. Використовуй літеральні типи або as const, щоб отримати точне зіставлення.

Q: Чи працює звуження через await?
A: Так, якщо змінна не перезаписується між перевіркою та await. TypeScript відстежує її через асинхронний потік управління, доки нічого не мутує її між цими точками.

Q: Як зробити switch-редюсер вичерпним через never?
A:

typescript
default: const _check: never = action; return _check;

Якщо додаєш новий тип екшену і забуваєш його обробити, TypeScript видасть помилку на присвоєнні never. Гілка default стає страховочною сіткою на етапі компіляції.

Приклади

Базовий: Union з примітивами та ранній return

typescript
function format(val: string | number | null) { if (val === null) return "N/A"; if (typeof val === "string") { return val.trim(); // val: string } return val.toLocaleString(); // val: number } format(" hello "); // "hello" format(1234567); // "1 234 567" format(null); // "N/A"

Три типи, три гілки, кожна чітко звужена. Ранній return для null - найпоширеніший патерн у продакшен-коді. Я використовую його за замовчуванням у кожній utility-функції, що приймає опціональні значення, бо він тримає основну логіку плоскою і читабельною.

Середній: React-компонент з дискримінованими пропсами

typescript
interface ButtonProps { kind: "button"; label: string; onClick: () => void; } interface LinkProps { kind: "link"; label: string; href: string; } type Props = ButtonProps | LinkProps; function InteractiveElement(props: Props) { if (props.kind === "button") { // props: ButtonProps - onClick існує тут return <button onClick={props.onClick}>{props.label}</button>; } // props: LinkProps - href існує тут return <a href={props.href}>{props.label}</a>; }

Поле kind - це дискримінант. TypeScript звужує до кожного конкретного інтерфейсу всередині відповідної гілки. Спроба звернутися до props.href у першій гілці - помилка компіляції. Цей патерн є в майже кожній бібліотеці компонентів, що підтримує поліморфний рендеринг.

Просунутий: Redux-редюсер з вкладеним звуженням

typescript
type Action = | { type: "LOAD"; status: "loading" | "success" | "error"; data?: string } | { type: "UPDATE"; value: number }; interface AppState { content: string; status: string; count: number; } function reducer(state: AppState, action: Action): AppState { if (action.type === "LOAD") { // action: { type: "LOAD"; status: ...; data?: string } if (action.status === "success" && action.data) { // Без '&& action.data' data залишається string | undefined. // TypeScript не дасть присвоїти її без явної перевірки. return { ...state, content: action.data }; } return { ...state, status: action.status }; } // action: { type: "UPDATE"; value: number } return { ...state, count: action.value }; }

Найскладніша частина: data опціональна (data?: string). Навіть після звуження action.type === "LOAD" і action.status === "success" потрібна ще перевірка && action.data, щоб звузити тип з string | undefined до string. TypeScript не звужує вкладені опціональні поля автоматично. Саме цей момент найчастіше ловить розробників mid-рівня на code review.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?