Питання на співбесіді з TypeScript
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
Переписувати все одразу ризиковано й довго. TypeScript дозволяє змішувати JavaScript і TypeScript в одному проєкті й переходити по файлу.
Крок 1 - tsconfig.json з дозволом JavaScript:
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"strict": false,
"noEmit": true
},
"include": ["resources/js"]
}
allowJs-.js-файли входять у проєкт: TypeScript-файли можуть їх імпортувати, редактор дає підказки;checkJs- перевіряти й.js-файли. Можна вмикати точково - коментарем// @ts-checkна початку окремого файлу.
Крок 2 - типи в JavaScript через JSDoc (без перейменування файлів):
// @ts-check
/**
* @param {number} amount
* @param {'UAH' | 'USD'} currency
* @returns {string}
*/
export function formatPrice(amount, currency) { /* ... */ }
/** @typedef {{ id: number, name: string }} User */
Крок 3 - перейменування файлів у .ts - починаючи з «листових» модулів без залежностей (утиліти, константи, API-клієнт), потім угору до компонентів.
Крок 4 - посилення строгості: спершу noImplicitAny, потім strictNullChecks, наприкінці повний strict.
Що змінилося в TypeScript 7 для JavaScript-файлів. Аналіз JSDoc став ближчим до звичайного TypeScript, частина старих конструкцій більше не розпізнається:
@enumне має особливого значення - потрібен@typedefнад(typeof Obj)[keyof typeof Obj];- синтаксис Closure (
function(string): void) замінюється на(s: string) => void; - одинокий
?як тип і постфіксний!не підтримуються; @classне робить функцію конструктором - потрібенclass.
Тому JSDoc у старих проєктах після оновлення може дати нові помилки.
Практичні поради:
- не змінювати поведінку разом з типами - міграція окремими комітами, без рефакторингу логіки;
anyдозволений як тимчасовий, але з позначкою (// TODO: тип) - і лінтер, що рахує їх кількість;- типи для відповідей API - одне з перших, що варто додати: вони дають найбільше користі;
@ts-expect-errorкраще за@ts-ignore- він сам повідомить, коли помилку виправлено і коментар можна прибрати.
Головна ідея 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), але без повної типізації за маршрутами.
TypeScript свідомо не є повністю надійним (sound): заради зручності й сумісності з JavaScript він пропускає деякі програми, що впадуть під час виконання. Важливо знати, де саме.
1. Доступ за індексом:
const items: string[] = [];
const first: string = items[0]; // компілюється, хоча first - undefined
first.toUpperCase(); // падіння
Закриває: "noUncheckedIndexedAccess": true - тоді items[0] має тип string | undefined. Те саме для об'єктів з індексною сигнатурою (Record<string, T>).
2. Коваріантність змінних масивів:
const dogs: Dog[] = [];
const animals: Animal[] = dogs; // дозволено
animals.push(new Cat()); // тепер у масиві dogs - кіт
Закриває: приймати readonly Animal[] у функціях, що не змінюють масив.
3. Біваріантність параметрів методів:
interface Handler {
handle(event: Event): void; // синтаксис методу - біваріантний
}
const h: Handler = { handle(e: MouseEvent) { e.clientX; } }; // дозволено
strictFunctionTypes (частина strict) перевіряє параметри контраваріантно, але лише для властивостей-функцій (handle: (e: Event) => void), а не для синтаксису методів. Тому для колбеків у власних інтерфейсах краще синтаксис властивості.
4. any - вимикає перевірку для всього, до чого торкається, і «заражає» вирази. Закриває: unknown для невідомих даних, правила @typescript-eslint/no-unsafe-*, noImplicitAny.
5. Твердження типу as і ! - компілятор вірить розробнику.
6. Зовнішні дані - відповідь API типізована так, як ви написали, а не так, як прийшло. Закриває лише перевірка під час виконання (Zod, Valibot).
7. Інші місця: опціональні властивості й undefined (закриває exactOptionalPropertyTypes), мутація після звуження в замиканні, некоректні .d.ts бібліотек.
Практичний набір налаштувань для надійності:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true
}
}
З TypeScript 6 strict увімкнено за замовчуванням, але noUncheckedIndexedAccess і exactOptionalPropertyTypes - ні, їх варто додати явно в нових проєктах.
TypeScript порівнює типи за структурою, а не за назвою. Два типи з однаковою формою - взаємозамінні:
type UserId = string;
type OrderId = string;
function loadUser(id: UserId) { /* ... */ }
const orderId: OrderId = 'ord_123';
loadUser(orderId); // жодної помилки - обидва просто string
Аліаси типів - лише інші назви того самого типу. Переплутати ідентифікатори, гроші в копійках і гривнях, сирий і екранований HTML компілятор не завадить.
Branded types додають до типу «мітку», якої не існує під час виконання, але яка робить типи несумісними:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
function loadUser(id: UserId) { /* ... */ }
const orderId = 'ord_123' as OrderId;
loadUser(orderId); // помилка: OrderId несумісний з UserId
Значення з міткою створюють лише в одному місці - функції-конструкторі, що перевіряє дані:
type Email = Brand<string, 'Email'>;
function toEmail(value: string): Email {
if (!/^[^@\s]+@[^@\s]+$/.test(value)) {
throw new Error(`Некоректна адреса: ${value}`);
}
return value as Email; // єдине місце з приведенням
}
function sendWelcome(to: Email) { /* тут адреса гарантовано перевірена */ }
Тип Email тепер означає «перевірений рядок» - функції, що його приймають, не мусять перевіряти повторно.
Застосування:
- ідентифікатори різних сутностей - не передати id замовлення туди, де потрібен id користувача;
- одиниці виміру:
Cents,Uah,Milliseconds,Seconds; - перевірені дані:
Email,SafeHtml,NonEmptyString,PositiveInt; - токени й секрети, які не можна випадково записати в лог як звичайний рядок.
Що варто знати:
- мітка існує лише в типах - під час виконання це звичайний рядок чи число, без витрат;
- операції над значенням (
id + '_x') повертають звичайнийstring- мітка губиться, і це правильно; - бібліотеки валідації підтримують мітки:
z.string().email().brand<'Email'>()у Zod; - класи з приватними полями - номінальні за природою: два класи з однаковою формою, але різними
#privateполями, несумісні.
Проблема винятків у TypeScript: сигнатура функції не каже, які помилки вона може кинути. function parse(s: string): Config може кинути що завгодно, а в catch (error) змінна має тип unknown. Компілятор не змусить обробити помилку.
Тип Result робить помилку частиною повернутого значення:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
type AgeError = 'not-a-number' | 'negative' | 'too-large';
function parseAge(input: string): Result<number, AgeError> {
const n = Number(input);
if (Number.isNaN(n)) return { ok: false, error: 'not-a-number' };
if (n < 0) return { ok: false, error: 'negative' };
if (n > 150) return { ok: false, error: 'too-large' };
return { ok: true, value: n };
}
const result = parseAge(form.age);
if (!result.ok) {
showError(messages[result.error]); // error: AgeError - відомо, які бувають
return;
}
saveAge(result.value); // value: number - лише після перевірки
Що це дає:
- помилки видно в сигнатурі - і їх неможливо «забути»: до
valueне дістатися без перевіркиok; - точні типи помилок - перелік очікуваних випадків, а не
unknown; - вичерпна обробка:
switch (result.error)з перевіркоюneverгарантує, що кожен варіант оброблено; - легко тестувати: функція повертає дані, а не кидає.
Коли Result виправданий:
- очікувані бізнес-помилки: валідація, «товару немає на складі», «недостатньо коштів», відповіді API з відомими кодами;
- місця, де помилку треба обробити поруч з викликом.
Коли краще винятки:
- неочікувані збої (баги, недоступна мережа, порушені інваріанти) - їх обробляють на межі застосунку, а не в кожному виклику;
- код, що працює з фреймворками, які очікують винятків (межі помилок React, обробники помилок у Laravel-стилі).
Пастки:
- ланцюжки Result-ів многослівні (
if (!r.ok) return r;на кожному кроці). Бібліотеки (neverthrow, Effect) даютьmap,andThen- але додають новий стиль коду, який команда має прийняти; - змішування стилів: функція, що повертає Result, але всередині може кинути виняток, - найгірший варіант. Межа має бути чіткою: на якому рівні винятки перетворюються на Result.
Помічник assertNever - замість того, щоб у кожному switch повторювати присвоєння never:
export function assertNever(value: never, message = 'Неочікуване значення'): never {
throw new Error(`${message}: ${JSON.stringify(value)}`);
}
type PaymentMethod =
| { type: 'card'; last4: string }
| { type: 'bank'; iban: string }
| { type: 'cash' };
function label(method: PaymentMethod): string {
switch (method.type) {
case 'card': return `Картка •${method.last4}`;
case 'bank': return `Рахунок ${method.iban}`;
case 'cash': return 'Готівка';
default: return assertNever(method);
}
}
Додали { type: 'crypto' } - компілятор вказує на assertNever(method): аргумент більше не never. А під час виконання (дані прийшли з API в обхід типів) - зрозумілий виняток замість тихого undefined.
Без switch - через об'єкт-мапу з повним набором ключів:
const icons = {
card: 'credit-card',
bank: 'building-columns',
cash: 'money-bill',
} satisfies Record<PaymentMethod['type'], string>;
Новий варіант у типі - помилка, що в мапі бракує ключа.
switch (true) зі звуженням (TypeScript 5.3+). Для умов, які не зводяться до одного поля:
function describe(value: string | number | Date | null): string {
switch (true) {
case value === null:
return '-';
case typeof value === 'string':
return value.trim(); // value: string
case typeof value === 'number':
return value.toFixed(2); // value: number
case value instanceof Date:
return value.toISOString(); // value: Date
default:
return assertNever(value);
}
}
Кожна гілка case звужує тип так само, як if. Це читабельніша альтернатива довгому ланцюжку if/else if.
Помічник match у функціональному стилі - типізований об'єкт обробників:
function match<T extends { type: string }, R>(
value: T,
handlers: { [K in T['type']]: (v: Extract<T, { type: K }>) => R },
): R {
return (handlers as any)[value.type](value);
}
match(method, {
card: (m) => m.last4,
bank: (m) => m.iban,
cash: () => 'готівка',
}); // пропущений обробник - помилка компіляції
Для складних випадків є бібліотека ts-pattern з повноцінним зіставленням зразків і перевіркою вичерпності.
Для бібліотек, спільних утиліт і складних генеріків типи - частина публічного API. Регресія в типі (функція почала повертати any, перестала ловити неправильний аргумент) - такий самий баг, як помилка в логіці. Звичайні тести її не помітять.
1. @ts-expect-error - перевірка, що помилка БУДЕ:
// @ts-expect-error - id має бути числом
loadUser('42');
Якщо рядок перестане бути помилкою, компілятор скаже Unused '@ts-expect-error' directive (TS2578). Так тестують, що тип забороняє неправильне використання.
Не плутати з @ts-ignore: той мовчки приховує будь-яку помилку і не скаже, якщо її вже немає. У коді застосунку @ts-ignore - майже завжди погана ідея; @ts-expect-error з поясненням чесніший.
2. expect-type - перевірка, що тип ТОЧНО такий:
import { expectTypeOf } from 'expect-type';
const row = queryBuilder.select('id', 'name').build();
expectTypeOf(row).toEqualTypeOf<{ id: number; name: string }>();
expectTypeOf(parseAge).returns.toEqualTypeOf<Result<number, AgeError>>();
expectTypeOf(useCart).toBeFunction();
Перевірка відбувається під час компіляції (tsc --noEmit). У Vitest той самий API вбудований (expectTypeOf), а режим vitest --typecheck запускає файли *.test-d.ts як типові тести.
3. tsd - окремий інструмент для .d.ts бібліотек: перевіряє типи з погляду споживача пакета (expectType, expectError). Використовує вбудовану версію компілятора, тож результат не залежить від TypeScript у проєкті.
Що тестувати:
- виведення типів генеріків і перевантажень - результат має бути точним, а не
anyчиunknown; - заборонені виклики - неправильні аргументи мають давати помилку;
- звуження: після type guard тип звужено правильно;
- утиліти типів (
DeepPartial,PathOf<T>) - на граничних випадках:never, об'єднання, порожні об'єкти,any.
Пастки:
- перевірка «дорівнює» складніша, ніж здається:
anyсумісний з усім, тож наївна перевірка через присвоєння пропуститьany.toEqualTypeOfвраховує це; - тести типів запускаються лише в CI з
tsc- Vite і esbuild при збиранні типи не перевіряють. Без крокуtsc --noEmitу CI типові тести нічого не ловлять.
Utility types - вбудовані перетворення типів, щоб не описувати схожі форми вручну:
interface User {
id: number;
name: string;
email: string;
password: string;
}
type UserUpdate = Partial<Omit<User, 'id'>>; // усі поля, крім id, необов'язкові
type PublicUser = Omit<User, 'password'>;
type UserPreview = Pick<User, 'id' | 'name'>;
type RolesMap = Record<'admin' | 'editor', string[]>;
type Frozen = Readonly<User>;
Ще корисні: Required, NonNullable, ReturnType<typeof fn>, Parameters<typeof fn>, Awaited<Promise<T>>.
Під капотом це mapped types - тип, що проходить по ключах іншого типу:
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Модифікатори можна і додавати, і знімати
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// Перейменування ключів через as (TS 4.1+)
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<Pick<User, 'name'>>; // { getName: () => string }
Навіщо на практиці: один джерельний тип (User), а похідні - форма оновлення, публічне представлення, стан форми - виводяться з нього. Додали поле в User - усі похідні оновилися, і компілятор покаже місця, які треба доробити.
Обережно: надто «розумні» типи важко читати й налагоджувати, а повідомлення про помилки в них стають незрозумілими. Якщо тип потрібно пояснювати, інколи простіше описати його явно.
Discriminated union - об'єднання об'єктних типів зі спільним полем-літералом («дискримінантом»). Перевірка цього поля звужує тип до конкретного варіанта.
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function render(state: RequestState) {
switch (state.status) {
case 'idle':
return 'Натисніть «Завантажити»';
case 'loading':
return 'Завантаження...';
case 'success':
return `${state.data.length} користувачів`; // data доступне лише тут
case 'error':
return `Помилка: ${state.error}`;
}
}
Чому це краще за набір необов'язкових полів ({ loading?: boolean; data?: User[]; error?: string }): неможливі стани (одночасно data і error, loading і data) стають непредставлюваними на рівні типів.
Перевірка вичерпності: якщо додати новий варіант, компілятор має вказати всі місця, де його не обробили:
function assertNever(value: never): never {
throw new Error(`Необроблений стан: ${JSON.stringify(value)}`);
}
switch (state.status) {
// ...усі case
default:
return assertNever(state);
}
У default після всіх case тип state звузився до never. Додали { status: 'cancelled' } - тепер у default потрапляє цей варіант, і передати його в параметр типу never не можна: помилка компіляції саме там, де треба доробити.
Де застосовують: стани запитів і форм, події та повідомлення (Redux actions, WebSocket-повідомлення), результати операцій ({ ok: true; value } | { ok: false; error }).
Розширення (widening) - TypeScript виводить для змінної ширший тип, ніж конкретне значення, якщо змінна може змінитися.
const a = 'draft'; // тип 'draft' - константа ніколи не зміниться
let b = 'draft'; // тип string - змінна може отримати інше значення
Об'єкти й масиви розширюються навіть у const, бо їх вміст змінюваний:
const post = { status: 'draft' }; // { status: string }
post.status = 'anything'; // дозволено
function publish(status: 'draft' | 'published') {}
publish(post.status); // помилка: string не 'draft' | 'published'
Способи зберегти вузький тип:
const post = { status: 'draft' as const }; // { status: 'draft' }
const post2 = { status: 'draft' } as const; // { readonly status: 'draft' }
const post3: { status: 'draft' | 'published' } = { status: 'draft' };
const post4 = { status: 'draft' } satisfies { status: 'draft' | 'published' };
as const- найвужчі літеральні типи йreadonlyна всіх рівнях;- анотація - тип визначає анотація (вона ж і обмежує);
satisfies- перевіряє відповідність, але зберігає виведений тип значення.
Контекстна типізація - виведення «ззовні»: якщо тип очікуваного значення відомий, літерал не розширюється:
type Options = { method: 'GET' | 'POST' };
const options: Options = { method: 'GET' }; // ок
fetchJson({ method: 'GET' }); // ок, якщо параметр типізовано як Options
Також параметри колбеків отримують типи з контексту: items.map((item) => ...).
«Найкращий загальний тип» для масивів - об'єднання типів елементів: [1, 'a'] - (string | number)[], а не кортеж.
Константні параметри типу (TS 5.0) - function f<const T>(x: T) змушує виводити T так, ніби аргумент записано з as const, без вимоги до викликача.
Типова помилка в коді:
const config = { mode: 'production' }; // mode: string
createApp(config); // помилка: очікується 'production' | 'development'
Рішення - satisfies AppConfig чи анотація типу при оголошенні, а не as при виклику: as вимикає перевірку значення.
Звуження після перевірок - зворотний процес: let x: string | number, а після typeof x === 'string' у гілці - string.
Чотири типи, які легко сплутати, і вони приймають різні множини значень.
| Тип | Що приймає |
|---|---|
unknown |
будь-що, включно з null і undefined |
{} |
будь-що, крім null і undefined (і рядки, і числа!) |
object |
лише не-примітиви: об'єкти, масиви, функції |
Object |
майже як {} (будь-що з методами Object.prototype), застарілий запис |
const a: {} = 5; // ок - число не null і не undefined
const b: {} = 'текст'; // ок
const c: {} = null; // помилка
const d: object = { }; // ок
const e: object = []; // ок
const f: object = 5; // помилка - примітив
Пастка {}. Його часто пишуть, маючи на увазі «порожній об'єкт» чи «якийсь об'єкт», а отримують «будь-яке ненульове значення». Правило лінтера @typescript-eslint/no-empty-object-type попереджає саме про це.
Що використовувати насправді:
- «будь-яке значення, перевірю пізніше» -
unknown; - «будь-який об'єкт, ключі невідомі» -
Record<string, unknown>. Доступ до поля даєunknown, і код змушений перевіряти; - «не-примітив» (наприклад, для
WeakMap-ключів чи функцій, що працюють з посиланнями) -object; - «справді порожній об'єкт, без полів» -
Record<PropertyKey, never>; - «будь-що, крім null/undefined» у узагальненнях - обмеження
T extends {}, наприклад у власномуNonNullable.
{} в узагальненнях - корисний інструмент: NonNullable<T> визначено як T & {} - перетин прибирає null і undefined з об'єднання.
Object з великої літери - тип обгортки, що лишився з ранніх версій. Документація радить ніколи його не використовувати.
Чому object не дає доступу до полів:
function print(value: object) {
value.name; // помилка: у object немає властивості name
}
object лише гарантує, що це не примітив. Щоб читати поля, тип має їх описувати або бути Record<string, unknown> з подальшою перевіркою.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії