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

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

Докладніше в документації: 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()

Головна ідея 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.

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

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

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

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-data: TypeScript

Фронтенд 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), але без повної типізації за маршрутами.

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

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: кодеки