Junior: питання на співбесіді з теми «Типізація даних і API»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Типи TypeScript існують лише під час компіляції. Після збирання від них не лишається нічого - браузер виконує звичайний JavaScript. Тому TypeScript перевіряє ваш код, але не дані, що приходять ззовні.
type User = { id: number; name: string; email: string };
const response = await fetch('/api/user');
const user: User = await response.json(); // response.json() повертає any
user.email.toLowerCase(); // компілюється, а якщо API повернув { data: {...} } - падає
Анотація : User - це обіцянка розробника, а не перевірка. Якщо бекенд змінив формат, перейменував поле чи повернув null, TypeScript про це не дізнається - помилка вилізе під час виконання, часто далеко від місця отримання даних.
Звідки беруться «неперевірені» дані:
- відповіді API (
response.json()має типPromise<any>); JSON.parse(тежany);localStorage, параметри URL,postMessage, WebSocket;- змінні оточення, значення полів форм.
Як захиститися - перевірка під час виконання на межі системи:
import * as z from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.email(),
});
type User = z.infer<typeof UserSchema>; // тип виводиться зі схеми
const user = UserSchema.parse(await response.json()); // кине помилку, якщо дані не такі
Схема і тип - одне джерело правди: змінили схему - змінився тип.
Правила:
- дані ззовні мають тип
unknown, доки не перевірені - а неany; - перевіряти один раз на межі (в API-клієнті), а далі код працює з надійно типізованими даними;
- помилка перевірки має бути помітною: зрозуміле повідомлення, лог у моніторинг - так розбіжність між бекендом і фронтендом виявляють одразу.
Альтернативи Zod: Valibot (менший розмір у збірці), ArkType, TypeBox. Ідея однакова - схема під час виконання, з якої виводиться тип.
У JavaScript throw може кинути що завгодно - не лише Error, а й рядок, число, об'єкт чи undefined. Сторонні бібліотеки й старий код справді так роблять. Тому TypeScript не може гарантувати, що в catch прийде Error.
З strict (опція useUnknownInCatchVariables) змінна в catch має тип unknown:
try {
await saveOrder(order);
} catch (error) {
console.log(error.message); // помилка: 'error' is of type 'unknown'
}
Звуження перед використанням:
try {
await saveOrder(order);
} catch (error) {
if (error instanceof ValidationError) {
showFieldErrors(error.errors);
} else if (error instanceof Error) {
showToast(error.message);
} else {
showToast('Невідома помилка');
report(error);
}
}
Допоміжна функція для повідомлення:
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'Невідома помилка';
}
Чому не catch (error: any): це повертає стару небезпечну поведінку - error.response.data.message компілюється й падає з TypeError, якщо помилка мережева й response немає. Явна анотація catch (error: Error) не дозволена - TypeScript не може цього гарантувати.
Пастки з instanceof:
- помилки з іншого вікна (iframe) чи іншої копії бібліотеки в збірці не проходять
instanceof- для них перевіряютьnameчи наявність полів; - власні класи помилок мають правильно наслідувати
Error(class HttpError extends Error) і задаватиname.
Помилки з fetch: fetch не кидає винятку на 404 чи 500 - лише на мережеві помилки. Перевірку response.ok і перетворення на власну помилку (HttpError зі статусом) робить ваш API-клієнт - тоді в catch вона розпізнається через instanceof.
Відхилені проміси - пастка: на параметр колбеку .catch((error) => ...) опція useUnknownInCatchVariables не поширюється, і він має тип any. Його варто явно анотувати: .catch((error: unknown) => ...) - а правило лінтера @typescript-eslint/use-unknown-in-catch-callback-variable нагадає про це. З async/await і try/catch проблеми немає.
Vite передає в клієнтський код змінні оточення з префіксом VITE_ через import.meta.env. За замовчуванням TypeScript знає лише вбудовані поля (MODE, DEV, PROD, BASE_URL, SSR), а власні змінні мають тип any чи не існують.
Підключити типи Vite - у tsconfig.json:
{ "compilerOptions": { "types": ["vite/client"] } }
(з TypeScript 6.0 types за замовчуванням порожній - без цього навіть import.meta.env невідомий).
Описати власні змінні - файл resources/js/env.d.ts (чи src/vite-env.d.ts):
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_NAME: string;
readonly VITE_REVERB_APP_KEY: string;
readonly VITE_REVERB_PORT?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Тепер import.meta.env.VITE_APP_NAME - string, редактор підказує назви, а друкарська помилка VITE_APP_NAM дасть помилку компіляції.
Важливо: тип - не гарантія наявності. Оголошення string не означає, що змінна справді задана в .env на сервері збирання. Відсутня змінна буде undefined в зібраному коді. Надійніше перевірити при старті застосунку:
import * as z from 'zod';
export const env = z
.object({
VITE_APP_NAME: z.string().min(1),
VITE_REVERB_PORT: z.coerce.number().default(443),
})
.parse(import.meta.env);
Неправильна конфігурація виявляється одразу з зрозумілим повідомленням, а не дивною поведінкою в продакшені. Заодно рядкові значення перетворюються на числа й булеві.
Що варто пам'ятати:
- усі значення з
.env- рядки:VITE_FEATURE_X=falseдає рядок'false', який уif- істина; - змінні з префіксом
VITE_потрапляють у зібраний JavaScript і видні будь-кому - секрети туди не кладуть; - значення підставляються під час збирання: зміна
.envна сервері без перезбирання нічого не змінить.
У Node.js-коді (конфіги, SSR) - process.env з типами з @types/node і така сама перевірка схемою.
Докладніше в документації: Vite: IntelliSense для TypeScript
Усі три описують «відповідність ключів значенням», але з різною точністю.
Індексна сигнатура - об'єкт з довільними ключами певного типу:
interface Prices {
[sku: string]: number;
}
Record<K, V> - те саме коротше, але з важливою можливістю: ключі можуть бути обмеженим набором:
type Prices = Record<string, number>; // як індексна сигнатура
type Labels = Record<'draft' | 'published' | 'archived', string>;
const labels: Labels = {
draft: 'Чернетка',
published: 'Опубліковано',
archived: 'В архіві', // пропустити ключ - помилка
};
З обмеженим набором ключів TypeScript вимагає всі ключі - додали новий статус у тип, і компілятор покаже кожен словник, де бракує перекладу. Це дуже корисно для мап статусів, перекладів, конфігурацій.
Map<K, V> - окрема структура даних під час виконання, а не тип об'єкта:
const cache = new Map<number, User>();
cache.set(user.id, user);
const cached = cache.get(5); // User | undefined
Коли що:
Звичайний об'єкт (Record) |
Map |
|
|---|---|---|
| ключі | рядки (і символи) | будь-які: числа, об'єкти |
| порядок | цілочисельні ключі сортуються | порядок додавання |
| JSON | серіалізується | {} - треба перетворювати |
| часте додавання й видалення | повільніше | оптимізовано |
| розмір | Object.keys(o).length |
map.size |
Пастки:
Record<string, V>бреше про наявність ключа:prices['unknown']має типnumber, хоча під час виконання -undefined. РятуєnoUncheckedIndexedAccess(тип стаєnumber | undefined) - уMap.get()це вбудовано;- ключі з даних користувача в звичайному об'єкті можуть зіткнутися з
__proto__чиconstructor. Для довільних ключів -MapабоObject.create(null); - числові ключі в об'єкті стають рядками:
{ 1: 'a' }має ключ'1'.
Правило: фіксований набір ключів - Record з об'єднанням; довільні ключі з даних, що часто змінюються, - Map; дані для JSON (відповіді API, конфіги) - об'єкти.
У JSON немає типу «дата». Laravel серіалізує дати моделей як рядки ISO 8601 ("2026-10-04T07:00:00.000000Z"), і JSON.parse повертає саме рядок. Тип Date в інтерфейсі нічого не перетворює - це лише твердження розробника.
interface Order {
id: number;
createdAt: Date; // неправда: під час виконання тут рядок
}
const order: Order = await response.json();
order.createdAt.getFullYear(); // TypeError: getFullYear is not a function
TypeScript помилки не покаже - відповідь response.json() має тип any.
Варіант 1 - чесний тип: описати те, що справді приходить, і перетворювати там, де потрібно:
interface Order {
id: number;
createdAt: string; // ISO 8601
}
const created = new Date(order.createdAt);
Варіант 2 - перетворення на межі через схему:
import * as z from 'zod';
const OrderSchema = z.object({
id: z.number(),
createdAt: z.iso.datetime({ offset: true }).transform((value) => new Date(value)),
});
type Order = z.infer<typeof OrderSchema>; // createdAt: Date - тепер це правда
const order = OrderSchema.parse(await response.json());
Рядок перевіряється на формат і перетворюється на Date; далі весь код працює з датою.
Нюанси з датами:
- дата без часу (
"2026-10-04") уnew Date()розбирається як північ UTC - у Києві це ще 4 жовтня, а в Нью-Йорку вже 3-тє. Для дат без часу (день народження, дата події) краще лишати рядок або використовуватиTemporal.PlainDate; - дата з поясом (
...Zчи+03:00) - однозначна мить, її безпечно перетворювати; - у зворотному напрямку
JSON.stringify(new Date())дає рядок ISO в UTC - Laravel його правильно розбере.
Те саме стосується інших типів, яких немає в JSON: BigInt (великі id - краще рядками), Map/Set, undefined (зникає), гроші (decimal з Laravel часто приходить рядком "125.50" - і це правильно, щоб не втратити точність).
Загальне правило: тип даних з API описує формат JSON, а не бажану модель. Перетворення - явний крок у API-клієнті.