Звуження типів у 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
Швидкий приклад
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-ів з примітивами:
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 для екземплярів класів:
function handleEvent(event: MouseEvent | KeyboardEvent) {
if (event instanceof MouseEvent) {
console.log(event.clientX); // event: MouseEvent
} else {
console.log(event.key); // event: KeyboardEvent
}
}Оператор in для форм об'єктів та інтерфейсів:
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-ів:
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:
function greet(name: string | null) {
if (name) {
console.log(name.toUpperCase()); // name: string
}
}Предикати типу (type predicates) для повторно використовуваних guard-ів:
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 НЕ звужує тут
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:
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 */ }
}Очікування, що звуження збережеться в колбеку:
function process(value: string | null) {
if (value !== null) {
setTimeout(() => {
// TypeScript може позначити помилку: value могло змінитись
console.log(value.toUpperCase());
}, 1000);
}
value = null; // змінено після перевірки
}Збережи звужене значення в const перед колбеком:
const v = value; // v: string
setTimeout(() => console.log(v.toUpperCase()), 1000);instanceof для примітивів:
// Неправильно - примітиви не є екземплярами класів
if (value instanceof String) { ... }
// Правильно
if (typeof value === "string") { ... }Забути перевірити опціональне поле після звуження union-у:
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:
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:
default:
const _check: never = action;
return _check;Якщо додаєш новий тип екшену і забуваєш його обробити, TypeScript видасть помилку на присвоєнні never. Гілка default стає страховочною сіткою на етапі компіляції.
Приклади
Базовий: Union з примітивами та ранній return
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-компонент з дискримінованими пропсами
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-редюсер з вкладеним звуженням
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.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.