Middle: питання на співбесіді з теми «Типізація даних і API»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Головна ідея Zod (і подібних бібліотек) - один опис даних дає і перевірку під час виконання, і тип для компілятора.
import * as z from 'zod';
export const VacancySchema = z.object({
id: z.number().int().positive(),
title: z.string().min(3),
salary: z.number().nullable(),
remote: z.boolean(),
tags: z.array(z.string()).default([]),
publishedAt: z.iso.datetime().transform((value) => new Date(value)),
});
export type Vacancy = z.infer<typeof VacancySchema>;
Vacancy виводиться автоматично - описувати інтерфейс окремо не потрібно, і тип не розійдеться зі схемою.
Вхідний і вихідний тип. Схема може перетворювати дані (transform, default, coerce), тож тип «до» і «після» перевірки різний:
type VacancyInput = z.input<typeof VacancySchema>;
// tags?: string[] | undefined (default - можна не передавати)
// publishedAt: string (рядок до перетворення)
type Vacancy = z.output<typeof VacancySchema>; // те саме, що z.infer
// tags: string[]
// publishedAt: Date
z.input- що схема приймає (дані форми, тіло запиту перед відправкою);z.output/z.infer- що схема повертає після перевірки.
parse чи safeParse:
const vacancy = VacancySchema.parse(data); // кидає ZodError
const result = VacancySchema.safeParse(data);
if (!result.success) {
console.error(z.prettifyError(result.error));
} else {
result.data; // Vacancy
}
safeParse повертає дискримінований тип - після перевірки success TypeScript знає, чи є data чи error.
Корисні можливості Zod 4:
- форматні валідатори на верхньому рівні:
z.email(),z.url(),z.uuid(),z.iso.datetime(); z.coerce.number()- перетворення рядків з форм і URL;.pick(),.omit(),.partial(),.extend()- похідні схеми, як утилітні типи TypeScript;z.strictObject- помилка на зайві поля (звичайнийz.objectїх мовчки прибирає).
Пастки:
- не дублювати тип вручну поряд зі схемою - вони розійдуться;
- перевірка не безкоштовна: великі списки (тисячі записів) перевіряти на кожен запит дорого - інколи досить перевіряти структуру першого рівня або вибірково;
- розмір у збірці: для фронтенду, де важливий кожен кілобайт, є
zod/miniчи Valibot з модульним API.
Розкидані по коду fetch з response.json() as User дають і дублювання, і неперевірені дані. Краще один клієнт, що поєднує запит, обробку помилок і перевірку схемою.
import * as z from 'zod';
export class HttpError extends Error {
constructor(public readonly status: number, public readonly body: unknown) {
super(`HTTP ${status}`);
this.name = 'HttpError';
}
}
export async function apiGet<S extends z.ZodType>(url: string, schema: S): Promise<z.infer<S>> {
const response = await fetch(url, { headers: { Accept: 'application/json' } });
if (!response.ok) {
throw new HttpError(response.status, await response.json().catch(() => null));
}
return schema.parse(await response.json());
}
Використання - тип виводиться зі схеми, жодних as:
const user = await apiGet('/api/users/1', UserSchema);
user.email; // string - і це перевірено
const page = await apiGet('/api/vacancies?page=2', paginated(VacancySchema));
Узагальнена схема для пагінації Laravel:
const paginated = <T extends z.ZodType>(item: T) =>
z.object({
data: z.array(item),
meta: z.object({ current_page: z.number(), last_page: z.number(), total: z.number() }),
});
Чому параметр типу S extends z.ZodType, а не T: тип результату виводиться з переданої схеми. Варіант apiGet<T>(url): Promise<T> без схеми - це прихований as: виклик apiGet<User>(url) виглядає типізованим, але нічого не перевіряє.
Що ще варто додати в клієнт:
- CSRF і cookies для Laravel (
X-XSRF-TOKEN,credentials), заголовокAccept: application/json, щоб помилки валідації приходили як JSON 422; - розбір 422 у типізовану помилку валідації з
errors: Record<string, string[]>; - скасування через
AbortSignal(параметрsignal) і тайм-аут; - POST/PUT з типізованим тілом:
apiPost<In, S>(url, body: In, schema: S).
Пастки:
response.json()на порожній відповіді (204 No Content) кидає помилку - обробляти окремо;- помилка перевірки схеми - це баг контракту між бекендом і фронтендом, а не помилка користувача. Її варто логувати з деталями (
z.prettifyError) у моніторинг, а користувачу показати загальне повідомлення.
Готові варіанти: ky, ofetch з хуками, або генерація клієнта з OpenAPI - тоді й схеми, й типи створюються автоматично.
as каже компілятору: «повір мені, тут такий тип». Під час виконання нічого не відбувається - ні перевірки, ні перетворення. Якщо розробник помилився, TypeScript мовчить, а помилка вилізе пізніше.
const user = (await response.json()) as User; // жодної перевірки
const input = document.querySelector('#email') as HTMLInputElement; // а якщо це <div> чи null?
Перевірка (звуження чи схема) доводить тип під час виконання:
const el = document.querySelector('#email');
if (!(el instanceof HTMLInputElement)) throw new Error('Поле email не знайдено');
el.value; // тепер справді HTMLInputElement
const user = UserSchema.parse(await response.json());
as не дозволяє будь-яке перетворення: 'text' as number - помилка, бо типи не перетинаються. Обхід через as unknown as number компілюється - і це майже завжди ознака проблеми в коді.
Споріднені конструкції з тими самими ризиками:
!(non-null assertion):user!.name- «тут точно неnull»;any- вимикає перевірку зовсім.
Коли as виправданий:
- TypeScript знає менше за вас, і це можна обґрунтувати: після власної перевірки, яку компілятор не розуміє, або в коді, що працює з DOM, де ви контролюєте розмітку;
as const- зовсім інша річ: не обхід перевірки, а звуження до літеральних типів (['draft', 'published'] as constдає кортеж рядкових літералів). Це безпечно й корисно;- тести - часткові фіктивні об'єкти (
{ id: 1 } as User); - межі зі сторонніми бібліотеками з неточними типами - з коментарем, чому.
Кращі альтернативи as:
satisfies- перевірити, що значення відповідає типу, не втрачаючи точного виведеного типу:
const routes = {
home: '/',
jobs: '/jobs',
} satisfies Record<string, string>;
// routes.home - літерал '/', а друкарська помилка в ключах типу Record<'home'|'jobs', string> була б помічена
- функції-перевірки типів (
value is User) і схеми; - анотація змінної (
const x: User = {...}) - вона перевіряє зайві й відсутні поля, аas- ні.
Правило лінтера @typescript-eslint/consistent-type-assertions і заборона as unknown as допомагають тримати кількість тверджень під контролем.
У Laravel-застосунку з Vue чи React одні й ті самі структури описуються двічі: PHP-класом на бекенді і TypeScript-типом на фронтенді. Ручна синхронізація рано чи пізно ламається - поле перейменували в PHP, а у фронтенді лишилося старе.
Генерація типів з PHP - пакет spatie/laravel-typescript-transformer:
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
#[TypeScript]
final class VacancyData
{
public function __construct(
public int $id,
public string $title,
public ?int $salary,
public VacancyStatus $status,
) {}
}
#[TypeScript]
enum VacancyStatus: string
{
case Draft = 'draft';
case Published = 'published';
}
php artisan typescript:transform
Результат:
export type VacancyData = {
id: number;
title: string;
salary: number | null;
status: VacancyStatus;
};
export type VacancyStatus = 'draft' | 'published';
PHP-енуми стають об'єднаннями рядкових літералів, ?int - number | null, колекції з PHPDoc (array<int, TagData>) - масивами.
Зі spatie/laravel-data це особливо зручно: data-об'єкти одночасно є DTO, правилами валідації й ресурсами відповіді - і з них же генеруються типи.
Як вбудувати в процес:
- генерувати в CI і перевіряти, що згенерований файл не змінився (
git diff --exit-code) - так забута регенерація виявляється одразу; - або генерувати під час збирання фронтенду і не зберігати результат у Git;
- для маршрутів - Laravel Wayfinder генерує типізовані функції для контролерів і названих маршрутів.
Обмеження, про які варто пам'ятати:
- типи описують формат, але не перевіряють дані під час виконання. Генерація прибирає розбіжність у коді, але не захищає від старої версії бекенду в кеші чи помилки серіалізації;
- Eloquent-моделі перетворювати напряму погано: у відповідь потрапляє лише те, що віддає ресурс, а не всі атрибути. Генерувати варто з DTO чи ресурсів - того, що справді відправляється;
- формат JSON: дати,
decimal(рядок),snake_caseчиcamelCase- тип має відповідати серіалізованому вигляду.
Альтернатива на рівні всього API - OpenAPI-специфікація (наприклад, згенерована пакетом на кшталт Scramble) і генерація клієнта з неї.
Фронтенд Laravel-застосунку постійно будує URL: /posts/${id}, /api/vacancies?page=2. Рядкові шляхи ламаються непомітно - маршрут перейменували в routes/web.php, а у фронтенді лишився старий.
Wayfinder генерує з маршрутів і контролерів Laravel типізовані TypeScript-функції:
composer require laravel/wayfinder
npm i -D @laravel/vite-plugin-wayfinder
php artisan wayfinder:generate
// vite.config.ts
import { wayfinder } from '@laravel/vite-plugin-wayfinder';
export default defineConfig({ plugins: [wayfinder() /* ... */] });
Плагін перегенеровує файли під час збирання й при зміні маршрутів чи контролерів у режимі розробки.
Використання - дії контролерів:
import { show, update } from '@/actions/App/Http/Controllers/PostController';
show(1); // { url: '/posts/1', method: 'get' }
show.url(1); // '/posts/1'
update({ post: 1 }); // { url: '/posts/1', method: 'put' }
Названі маршрути:
import { show } from '@/routes/post'; // маршрут post.show
show(1).url;
Що дає:
- помилка компіляції при зміні маршруту: видалили метод контролера чи змінили параметри - TypeScript покаже кожне місце використання;
- правильний HTTP-метод разом з URL - не треба пам'ятати,
PUTчиPATCH; - параметри з прив'язкою моделей: приймає id, об'єкт
{ id }чи ключ ({ slug }), якщо маршрут задає{post:slug}; - форми: з
--with-form- атрибути для<form>({...store.form()}), включно з підміною методу.
У стартових наборах Laravel з React і Vue Wayfinder уже налаштовано, і з Inertia він використовується для Link та router.visit.
Що варто знати:
- згенеровані каталоги (
resources/js/actions,routes,wayfinder) можна не зберігати в Git - вони повністю створюються заново при збиранні; - кешовані маршрути при деплої: якщо
route:cacheлишився від попереднього релізу, генерація візьме старі маршрути - перед збиранням фронтенду потрібенroute:clear; - Wayfinder типізує URL і параметри маршруту, але не тіло запиту й відповідь - для них потрібні окремі типи (DTO, OpenAPI);
- пакет на момент написання в бета-версії - API може змінюватися до 1.0.
Попередник - Ziggy (функція route('post.show', id) у JavaScript), але без повної типізації за маршрутами.