Питання на співбесіді з TypeScript
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
Великий кодовий масив в одному tsconfig.json перевіряється цілком при кожній зміні. Project references ділять його на окремі проєкти з явними залежностями, і TypeScript перевіряє лише змінене та залежне від нього.
Структура:
// tsconfig.json (корінь) - лише посилання
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/web" },
{ "path": "./packages/admin" }
]
}
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"rootDir": "./src",
"outDir": "./dist"
}
}
// packages/web/tsconfig.json
{
"references": [{ "path": "../shared" }]
}
tsc -b # зібрати всі проєкти в порядку залежностей
tsc -b --watch
composite: true - вимоги до проєкту, на який посилаються: генерація .d.ts, явний rootDir, усі файли в include. Залежний проєкт бачить лише оголошення (.d.ts) залежності, а не її вихідний код - тому перевірка швидша.
incremental - зберегти результати попередньої перевірки у файлі .tsbuildinfo і наступного разу перевіряти лише змінене. Для composite увімкнено автоматично.
Що це дає:
- швидкість: зміна в
adminне змушує перевірятиweb; - межі: пакет не може імпортувати з іншого, якщо на нього немає посилання, - архітектура перевіряється компілятором;
- різні налаштування для частин: код браузера з
lib: ["dom"], конфіги й сервер - з типами Node.js. Саме так шаблон Vite ділить проєкт наtsconfig.app.jsonіtsconfig.node.json.
TypeScript 7 збирає незалежні проєкти паралельно (прапорець --builders), тож виграш від поділу ще більший. Обмежувач - граф залежностей: проєкт збирається лише після тих, від яких залежить. Опція isolatedDeclarations дає змогу генерувати .d.ts без перевірки типів залежностей і розпаралелити й це.
Пастки:
rootDirз TypeScript 6.0 за замовчуванням - каталог зtsconfig.json. Якщо вихідні файли вsrc/, аrootDirне вказано, результат опиниться вdist/src/...замістьdist/...;- застарілі
.d.ts: редактор може показувати старі типи залежності, доки її не перезібрано; skipLibCheck(пропустити перевірку.d.ts) теж помітно пришвидшує збірку, але ховає помилки в оголошеннях власних пакетів - його вмикають свідомо.
TypeScript 6.0 - перехідний реліз: останній на старому компіляторі, який готує проєкти до TypeScript 7. Він змінює значення за замовчуванням і оголошує застарілими опції, які в 7.0 стали помилками.
Нові значення за замовчуванням:
| Опція | Було | Стало |
|---|---|---|
strict |
false |
true |
module |
залежно від target |
esnext |
target |
es5 |
останній стабільний стандарт (es2025) |
types |
усі @types/* |
[] |
rootDir |
спільний каталог вихідних файлів | каталог з tsconfig.json |
noUncheckedSideEffectImports |
false |
true |
Найчастіші проблеми після оновлення:
- «Cannot find name 'process'» / «describe» - додати
"types": ["node", ...]; - результат збирається в
dist/src/index.jsзамістьdist/index.js- вказати"rootDir": "./src"; - нові помилки строгості - або виправляти, або явно
"strict": falseяк тимчасовий крок.
Видалено в TypeScript 7 (у 6.0 - попередження, які можна приглушити "ignoreDeprecations": "6.0"):
baseUrl- шляхи вpathsтепер відносно каталогу конфігу з префіксом./;moduleResolution: node(node10) іclassic- замінити наbundlerабоnodenext;target: es5іdownlevelIteration- найнижча цільes2015; для ES5 - зовнішній компілятор;module: amd,umd,systemjs,noneіoutFile;esModuleInterop: false,allowSyntheticDefaultImports: false,alwaysStrict: false;- ключове слово
moduleдля просторів імен (лишеnamespace) іassertsв імпортах (лишеwith); - передача файлів у
tscу каталозі зtsconfig.jsonбез--ignoreConfig.
Рекомендований порядок:
- оновитися до TypeScript 6.0 і виправити всі попередження про застарілі опції (не приглушувати
ignoreDeprecationsнадовго - у 7.0 це не спрацює); - частину змін (
baseUrl,rootDir) робить автоматично експериментальний інструментts5to6; - перевірити з прапорцем
stableTypeOrdering- у 7.0 він увімкнений і незмінний; з ним 6.0 дає ті самі результати, що й 7.0; - перейти на TypeScript 7, а для інструментів, що потребують API (
typescript-eslint), лишити поряд@typescript/typescript6.
Чому ці зміни корисні: явний types пришвидшує перевірку на 20-50%, а видалені опції здебільшого ховали помилки (TypeScript «бачив» імпорти, які не працювали під час виконання).
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.
Наївна шина подій - on(event: string, handler: (data: any) => void): назву події легко переплутати, а дані не типізовані. Мета - щоб для кожної події тип даних виводився автоматично.
Карта подій як тип:
type AppEvents = {
'user:created': { id: number; email: string };
'cart:updated': { count: number };
'session:expired': void;
};
Типізована шина:
class EventBus<E extends Record<string, unknown>> {
private handlers: { [K in keyof E]?: Array<(payload: E[K]) => void> } = {};
on<K extends keyof E>(event: K, handler: (payload: E[K]) => void): () => void {
(this.handlers[event] ??= []).push(handler);
return () => {
this.handlers[event] = this.handlers[event]?.filter((h) => h !== handler);
};
}
emit<K extends keyof E>(event: K, ...args: E[K] extends void ? [] : [payload: E[K]]): void {
for (const handler of this.handlers[event] ?? []) {
handler(args[0] as E[K]);
}
}
}
const bus = new EventBus<AppEvents>();
bus.on('user:created', (user) => user.email); // user: { id; email }
bus.emit('cart:updated', { count: 3 });
bus.emit('session:expired'); // без аргументу
bus.emit('cart:updated', { count: '3' }); // помилка
bus.on('user:deleted', () => {}); // помилка: немає такої події
Що тут працює:
K extends keyof E- назва події з'єднує виклик з типом даних:E[K]- дані саме цієї події;- mapped type
{ [K in keyof E]?: ... }- сховище обробників, типізоване для кожної події окремо; - кортеж залишкових параметрів
E[K] extends void ? [] : [payload: E[K]]- для подій без даних аргумент не потрібен, для решти обов'язковий; - повернена функція відписки - зручно для
useEffectчиonUnmounted.
Варіації:
- шаблонні літеральні типи для груп подій:
Extract<keyof E, \cart:${string}`>` - підписка на всі події кошика; EventTarget+CustomEvent- браузерна реалізація; типізувати її можна тим самим прийомом через перевантаженняaddEventListener;- готові бібліотеки (
mitt,nanoevents) приймають таку саму карту подій генеріком.
Де ще працює цей патерн «карта типів + keyof»: типізовані маршрути API (Endpoints['GET /users']), повідомлення postMessage між вікнами чи воркерами, події WebSocket-каналів, ключі localStorage з типами значень.
У fluent API (будівник запитів, конструктор форм, конфігурація) кожен виклик повертає об'єкт для наступного кроку. З генеріками кожен крок може уточнювати тип результату.
Приклад - запит, що знає, які поля вибрано:
type User = { id: number; name: string; email: string; passwordHash: string };
class Query<T extends object, Selected extends keyof T = never> {
constructor(private readonly fields: ReadonlyArray<keyof T> = []) {}
select<K extends keyof T>(...keys: K[]): Query<T, Selected | K> {
return new Query<T, Selected | K>([...this.fields, ...keys]);
}
async get(): Promise<Array<Pick<T, Selected>>> {
/* виконати запит з this.fields */
return [];
}
}
const users = await new Query<User>().select('id').select('name', 'email').get();
// users: Array<{ id: number; name: string; email: string }>
users[0].passwordHash; // помилка: поле не вибрано
new Query<User>().select('nope'); // помилка: такого поля немає
Ключова ідея: генерічний параметр (Selected) - «акумулятор». Кожен метод повертає новий тип з доповненим акумулятором (Selected | K). Через це методи мають повертати новий екземпляр з новим типом, а не this.
Обов'язкові кроки й порядок викликів - так само через параметри-прапорці:
class RequestBuilder<HasUrl extends boolean = false> {
declare private readonly hasUrl: HasUrl; // «фантомне» поле: лише для типів
url(value: string): RequestBuilder<true> { /* ... */ return this as unknown as RequestBuilder<true>; }
send(this: RequestBuilder<true>): Promise<Response> { /* ... */ }
}
new RequestBuilder().send(); // помилка: спочатку url()
new RequestBuilder().url('/api').send(); // ок
Параметр this у методі обмежує, на якому «етапі» метод доступний. Пастка: без поля hasUrl параметр HasUrl ніде не використовується в структурі класу - і через структурну типізацію RequestBuilder<false> вважається сумісним з RequestBuilder<true>, тож заборона мовчки не працює. Генерічний параметр-«прапорець» має бути частиною форми типу.
Де це використовується: Drizzle і Kysely (типізовані SQL-запити), tRPC, Zod (z.object(...).extend(...) накопичує форму), конструктори форм.
Ціна й межі:
- складні типи сповільнюють компілятор і редактор: глибокі ланцюжки з умовними типами на кожному кроці помітно гальмують підказки;
- повідомлення про помилки стають довгими й важкими для читання - типовий біль бібліотек на кшталт ORM;
- для власного коду застосунку часто досить простішого рішення: об'єкт параметрів з точним типом замість ланцюжка викликів.
Такий підхід виправданий у бібліотеках і спільних інструментах, які використовують багато разів, - там вкладення в типи окупається.
Типи бібліотек бувають неповними, застарілими чи надто загальними (any). Є кілька способів це виправити - від найбезпечнішого до найризикованішого.
1. Обгортка з точними типами - найнадійніше:
import { get } from 'legacy-http'; // повертає Promise<any>
export async function fetchJson<T>(url: string, schema: z.ZodType<T>): Promise<T> {
return schema.parse(await get(url));
}
Решта коду працює з вашою функцією, а не з бібліотекою. any локалізовано в одному місці і перевірено під час виконання.
2. Доповнення модуля (module augmentation) - додати до існуючих типів те, що бібліотека дозволяє розширювати:
// types/vue-router.d.ts
import 'vue-router';
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean;
title?: string;
}
}
Працює лише з інтерфейсами (їх можна «доповнювати» через злиття оголошень), а не з аліасами type. Багато бібліотек навмисно лишають такі «точки розширення»: RouteMeta у Vue Router, ComponentCustomProperties у Vue, Register у TanStack Router, теми в styled-components.
3. Глобальні доповнення:
declare global {
interface Window {
analytics?: { track(event: string, props?: Record<string, unknown>): void };
}
}
export {};
4. Оголошення для пакета без типів:
// types/untyped-lib.d.ts
declare module 'untyped-lib' {
export function format(value: number, options?: { currency?: string }): string;
}
Краще описати лише використану частину API, ніж declare module 'untyped-lib'; (усе стає any).
5. Латка пакета (patch-package, pnpm patch) - виправити .d.ts прямо в node_modules. Крайній засіб: латку треба підтримувати при кожному оновленні.
Що варто знати:
- файл з доповненням має бути модулем (мати
importчиexport), інакшеdeclare moduleстворить новий модуль замість доповнення існуючого; - файл має потрапити в компіляцію - через
includeуtsconfig; - з TypeScript 6
typesза замовчуванням порожній - глобальні типи з@types/*(наприклад,@types/node) треба перелічувати явно в"types": ["node"]; - внесок в оригінал: виправлення типів у DefinitelyTyped чи в саму бібліотеку прибирає потребу в латках для всіх.
Система типів TypeScript тюрінг-повна: на рівні типів можна парсити рядки, рахувати й будувати складні перетворення. Це не означає, що так варто робити в коді застосунку.
Ознаки переускладнених типів:
- тип важче зрозуміти, ніж код, який він описує. Новий розробник витрачає годину, щоб зрозуміти, чому помилка компіляції;
- повідомлення про помилки на десятки рядків з вкладеними умовними типами - замість «очікувалося число»;
- редактор гальмує: підказки з'являються з затримкою,
tscпрацює хвилинами; as anyпоруч зі складним типом - ознака, що тип не впорався з реальністю;- типи заради типів: рекурсивні утиліти для одного виклику, які можна замінити явним інтерфейсом.
Принципи розумної типізації:
- простіше - краще: явний
interfaceз переліком полів читабельніший заOmit<Pick<A, ...> & Partial<B>, ...>. Дублювання кількох полів інколи дешевше за складну похідну; - складність - у бібліотеках, простота - у застосунку. Генеріки й умовні типи виправдані в спільних інструментах (клієнт API, будівник форм), які використовуються сотні разів;
- типи на межах, виведення всередині: явно типізувати публічні функції, параметри, повернені значення модулів - а всередині функцій покладатися на виведення;
- дані з зовнішнього світу - схема валідації (Zod), з якої виводиться тип, а не вручну написаний «ідеальний» тип;
- читабельні назви проміжних типів замість одного гігантського виразу.
Продуктивність компілятора (рекомендації з вікі TypeScript):
- інтерфейси замість перетинів (
interface A extends B, CзамістьB & C) - їх відношення кешуються; - явні типи повернення у великих функціях - компілятору не треба виводити їх щоразу;
- уникати великих об'єднань (сотні варіантів) і глибокої рекурсії в умовних типах;
tsc --extendedDiagnosticsі--generateTrace- знайти, які файли й типи забирають найбільше часу.
TypeScript 7 (нативний компілятор на Go) пришвидшив перевірку в рази, але це не скасовує проблему: складні типи все одно важко читати й підтримувати, а помилки в них - розуміти.
Тест на доречність: чи стане коду помітно безпечніше від цього типу, і чи зрозуміє його колега без вашої допомоги? Якщо на обидва питання відповідь «ні» - простіший тип кращий.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії