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

Senior: питання на співбесіді з теми «Типізація даних і API»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

OpenAPI - машиночитаний опис API: маршрути, методи, параметри, тіла запитів, відповіді й коди помилок. Якщо специфікація є, типи на клієнті можна генерувати, а не писати вручну.

openapi-typescript перетворює специфікацію на TypeScript-типи:

npx openapi-typescript ./storage/api-docs/openapi.json -o ./resources/js/api/schema.d.ts

openapi-fetch - тонкий клієнт поверх fetch, типізований згенерованою схемою:

import createClient from 'openapi-fetch';
import type { paths } from './schema';

const api = createClient<paths>({ baseUrl: '/api' });

const { data, error } = await api.GET('/vacancies/{id}', {
  params: { path: { id: 42 } },
});

if (error) {
  // тип error - з описаних у специфікації відповідей з помилками
} else {
  data.title;   // тип відповіді 200
}
  • шлях перевіряється: неіснуючий маршрут - помилка компіляції;
  • параметри шляху, запиту й тіло - типізовані й обов'язкові, де вимагає специфікація;
  • відповідь - різні типи для успіху й помилок.

Звідки специфікація в Laravel: пакети, що генерують OpenAPI з коду (наприклад, Scramble - аналізує маршрути, Form Request і ресурси), або написана вручну специфікація як контракт (підхід «спершу специфікація»).

Що це дає:

  • один контракт для бекенду, фронтенду, мобільних клієнтів і документації;
  • зміни API видно в diff згенерованих типів - і компілятор показує місця, що зламалися;
  • документація (Swagger UI, Scalar) з того самого джерела.

Обмеження й пастки:

  • типи не перевіряють дані під час виконання. Згенерований тип каже, що поле є, але якщо реалізація розійшлася зі специфікацією - помилка під час виконання. Для критичних даних додають перевірку (є генератори Zod-схем з OpenAPI);
  • якість специфікації = якість типів. Автогенерація з коду може пропускати nullable-поля чи неочевидні формати - їх уточнюють анотаціями;
  • регенерація в CI: перевіряти, що згенеровані файли відповідають поточній специфікації, інакше вони непомітно застаріють;
  • версіонування API: зміни, що ламають сумісність, у специфікації мають бути явними - генерація типів робить їх видимими, але не вирішує проблему клієнтів, які ще не оновилися.

Коли варто: публічне чи велике внутрішнє API, кілька клієнтів, окремі команди бекенду й фронтенду. Для невеликого монолітного Laravel + Inertia - часто досить генерації типів з DTO і Wayfinder.

Докладніше в документації: openapi-typescript

TypeScript має структурну типізацію: типи сумісні, якщо мають однакову форму. Для ідентифікаторів це пастка:

type UserId = number;
type OrderId = number;

function cancelOrder(orderId: OrderId) { /* ... */ }

const userId: UserId = 42;
cancelOrder(userId);   // компілюється - обидва просто number

Переплутані аргументи (transfer(toId, fromId)), id одного ресурсу замість іншого, ціна в копійках замість гривень - типи цього не бачать.

Брендований тип додає до примітиву «позначку», якої немає під час виконання, але яку перевіряє компілятор:

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<number, 'UserId'>;
type OrderId = Brand<number, 'OrderId'>;

declare function cancelOrder(id: OrderId): void;

const userId = 42 as UserId;
cancelOrder(userId);   // помилка: тип 'UserId' не сумісний з 'OrderId'

Під час виконання це звичайне число - жодних накладних витрат.

Звідки беруться брендовані значення. as UserId скрізь у коді знищив би користь. Значення має створюватися в одному місці - на межі, після перевірки:

import * as z from 'zod';

const UserIdSchema = z.number().int().positive().brand('UserId');
type UserId = z.infer<typeof UserIdSchema>;

const UserSchema = z.object({ id: UserIdSchema, name: z.string() });
const user = UserSchema.parse(json);   // user.id має тип UserId

Або функція-конструктор з перевіркою: function toCents(uah: number): Cents.

Де брендування найкорисніше:

  • ідентифікатори різних сутностей, які легко переплутати;
  • одиниці виміру: копійки й гривні, мілісекунди й секунди, пікселі й rem;
  • перевірені значення: Email, NonEmptyString, SanitizedHtml - функція, що приймає SanitizedHtml, гарантовано не отримає неочищений рядок;
  • ключі й токени, які не можна передавати в журнали.

Обмеження:

  • арифметика губить бренд: cents + cents - звичайний number, результат треба знову «позначити»;
  • серіалізація: у JSON брендів немає - після JSON.parse значення знову треба перевірити схемою;
  • перебір з брендами ускладнює код без користі - застосовувати там, де помилка дорога (гроші, доступ, ідентифікатори в API).

Докладніше в документації: Zod: брендовані типи

Laravel на помилку валідації для запиту з Accept: application/json повертає 422 з тілом:

{
  "message": "The email field must be a valid email address. (and 1 more error)",
  "errors": {
    "email": ["The email field must be a valid email address."],
    "items.0.qty": ["The items.0.qty field must be at least 1."]
  }
}

Типізований розбір на клієнті:

import * as z from 'zod';

const LaravelValidationError = z.object({
  message: z.string(),
  errors: z.record(z.string(), z.array(z.string())),
});

export class ValidationError<F extends string = string> extends Error {
  constructor(public readonly errors: Partial<Record<F, string[]>>) {
    super('Validation failed');
    this.name = 'ValidationError';
  }
}

if (response.status === 422) {
  const body = LaravelValidationError.parse(await response.json());
  throw new ValidationError(body.errors);
}

Прив'язка до полів форми. Ключі помилок мають відповідати полям - це можна перевірити типами:

type OrderForm = { email: string; items: { qty: number }[] };
type FieldPath = 'email' | `items.${number}.qty`;

function firstError(errors: Partial<Record<FieldPath, string[]>>, field: FieldPath) {
  return errors[field]?.[0];
}

Шаблонний рядковий тип `items.${number}.qty` описує вкладені ключі масивів так само, як їх формує Laravel.

Одна схема для клієнтської й серверної помилки. Якщо форма перевіряється Zod на клієнті, помилки Zod варто привести до того самого формату, що й у Laravel:

const result = OrderFormSchema.safeParse(values);
if (!result.success) {
  const { fieldErrors } = z.flattenError(result.error);   // { email: ['...'], ... }
}

Тоді компонент форми показує помилки однаково, незалежно від того, звідки вони прийшли.

Що варто врахувати:

  • клієнтська перевірка - для зручності, серверна - для захисту. Сервер перевіряє завжди, і його помилки мають показуватися навіть тоді, коли клієнтська схема їх «пропустила» (наприклад, унікальність email);
  • ключі вкладених полів: у Laravel items.0.qty, у бібліотеках форм - часто items[0].qty. Потрібне перетворення в одному місці;
  • мова повідомлень: Laravel повертає їх мовою застосунку (lang/uk/validation.php), клієнтські повідомлення Zod теж треба локалізувати (z.config() з українською локаллю чи власні повідомлення);
  • Inertia працює інакше: помилки валідації приходять не відповіддю 422, а як props errors після редиректу, і useForm розкладає їх за полями сам.

Докладніше в документації: Zod: форматування помилок

Дані змінюють форму, коли перетинають межу застосунку: з JSON у внутрішню модель (рядок → Date, копійки → об'єкт грошей, snake_case → camelCase) і назад - при відправці на сервер. transform у схемі описує лише один напрямок; зворотне перетворення доводиться писати окремо, і два описи розходяться.

Кодек (z.codec, з Zod 4.1) описує обидва напрямки в одному місці:

import * as z from 'zod';

const isoDatetimeToDate = z.codec(
  z.iso.datetime({ offset: true }),   // вхід: рядок ISO з JSON
  z.date(),                            // вихід: Date у застосунку
  {
    decode: (iso) => new Date(iso),
    encode: (date) => date.toISOString(),
  },
);

const EventSchema = z.object({
  title: z.string(),
  startsAt: isoDatetimeToDate,
});

const event = z.decode(EventSchema, json);            // startsAt: Date
const payload = z.encode(EventSchema, editedEvent);   // startsAt: string для відправки
  • decode - з «дротового» формату у внутрішній (як parse);
  • encode - зворотно, з перевіркою, що результат відповідає вхідній схемі.

Де це корисно:

  • дати й час - рядки ISO в JSON, Date чи Temporal у коді;
  • гроші - decimal з Laravel приходить рядком "125.50", у застосунку - ціле число копійок чи об'єкт з валютою;
  • ідентифікатори - числа в JSON, брендовані типи в коді;
  • JSON у рядку (поле meta як рядок) - розбір і зворотна серіалізація;
  • параметри URL - рядки ↔ числа, булеві, масиви.

Альтернативи без кодеків: окремі функції fromApi() і toApi() у шарі API-клієнта. Працює, але вимагає дисципліни - кожне нове поле треба не забути додати в обидві функції. Кодек робить пропуск помітним: схема одна.

Принципи роботи з межею:

  • перетворювати один раз - в API-клієнті, а не в компонентах;
  • внутрішня модель не мусить збігатися з форматом API: зручні назви, правильні типи, без полів, які інтерфейсу не потрібні;
  • зміни формату API тоді торкаються лише одного місця - схеми на межі.

Обмеження: не кожне перетворення має обернене (обрізання пробілів, втрата точності) - для таких лишається однобічний transform.

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