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

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.

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