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.
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).
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розкладає їх за полями сам.
Дані змінюють форму, коли перетинають межу застосунку: з 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.