Питання на співбесіді: Типізація даних і API
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
Типи 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-клієнті.
Головна ідея 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), але без повної типізації за маршрутами.
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.