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

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 - усі похідні оновилися, і компілятор покаже місця, які треба доробити.

Обережно: надто «розумні» типи важко читати й налагоджувати, а повідомлення про помилки в них стають незрозумілими. Якщо тип потрібно пояснювати, інколи простіше описати його явно.

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

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 }).

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

Розширення (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> з подальшою перевіркою.

Докладніше в документації: Тип object

У 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)).

Докладніше в документації: Оператор typeof для типів