Senior: питання на співбесіді з теми «Типи TypeScript»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
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> з подальшою перевіркою.
У TypeScript є два окремих «простори імен»:
- простір значень - те, що існує під час виконання: змінні, функції, класи, об'єкти;
- простір типів - те, що існує лише для компілятора і зникає в JavaScript:
type,interface.
Одне ім'я може одночасно бути і значенням, і типом - і це різні сутності:
const Status = { Draft: 'draft', Published: 'published' } as const; // значення
type Status = (typeof Status)[keyof typeof Status]; // тип з тим самим іменем
function setStatus(status: Status) {} // тут Status - тип
setStatus(Status.Draft); // тут Status - значення
Що належить до обох просторів одразу:
- клас - і конструктор (значення), і тип екземпляра:
class User { constructor(public name: string) {} }
const u: User = new User('Оля'); // User - тип екземпляра і значення-конструктор
type UserCtor = typeof User; // тип самого класу (конструктора)
enum- об'єкт під час виконання і тип його значень;- простори імен (
namespace) з кодом.
Переходи між просторами:
typeof значення- отримати тип зі значення:typeof config,typeof fetchUser;ReturnType<typeof fn>,InstanceType<typeof User>- похідні типи;- зворотного переходу немає: тип не може стати значенням. Неможливо пройтися циклом по ключах
interfaceпід час виконання, перевіритиinstanceofдляtypeчи отримати список значень з об'єднання літералів.
Практичні наслідки:
- джерело правди - значення, тип з нього виводять. Масив
['draft', 'published'] as constдає і список для<select>, і тип(typeof STATUSES)[number]. Навпаки - з типу список не отримати; - схеми валідації (Zod) - те саме: схема - значення, що існує під час виконання, а тип виводиться з неї (
z.infer<typeof schema>); import typeімпортує лише з простору типів - такий імпорт гарантовано зникає після компіляції. ЗverbatimModuleSyntaxкомпілятор вимагає позначати імпорти лише типів явно;- помилка «X only refers to a type, but is being used as a value here» - спроба використати тип як значення (
instanceof Interface,Object.keys(SomeType)).