Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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. Ідея однакова - схема під час виконання, з якої виводиться тип.

Докладніше в документації: Zod: огляд

У 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 проблеми немає.

Докладніше в документації: TypeScript 4.4: unknown у 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-клієнті.

Докладніше в документації: JSON.parse()