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

Junior: питання на співбесіді з теми «Типи TypeScript»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

6 питань

Для опису форми об'єкта обидва працюють майже однаково:

interface User {
  id: number;
  name: string;
}

type UserType = {
  id: number;
  name: string;
};

Відмінності:

  • type описує будь-що, не лише об'єкти: об'єднання (type Status = 'draft' | 'published'), кортежі, примітиви, умовні й зіставлені типи. interface - лише форму об'єкта (і функції, класу).
  • Злиття оголошень: два interface з однаковим іменем зливаються в один. Так розширюють чужі типи - наприклад, додають поле до Window. type з тим самим іменем - помилка.
  • Розширення: interface Admin extends User {} проти type Admin = User & { role: string }. При конфлікті полів extends одразу дає зрозумілу помилку, а перетин & може мовчки дати never.
  • Повідомлення про помилки з інтерфейсами інколи читабельніші, а великі ієрархії інтерфейсів компілятор перевіряє трохи швидше.

Практичне правило: interface - для форм об'єктів і публічних API (їх можна розширити), type - для об'єднань, кортежів і всього обчислюваного. Головне - послідовність у кодовій базі: обидва варіанти правильні.

Докладніше в документації: Аліаси типів та інтерфейси

Обидва означають «тут може бути будь-яке значення», але з протилежним ставленням до перевірок:

  • any вимикає перевірку типів. З ним можна робити що завгодно - викликати методи, звертатися до полів, передавати куди завгодно. Компілятор мовчить, а помилка вилізе під час виконання.
  • unknown - безпечний аналог: присвоїти в нього можна будь-що, але використати значення не можна, доки не доведете, що воно потрібного типу.
const a: any = JSON.parse(text);
a.user.name.toUpperCase();     // компілюється - і може впасти

const u: unknown = JSON.parse(text);
u.user;                        // помилка компіляції

if (typeof u === 'object' && u !== null && 'user' in u) {
  // тут TypeScript знає більше
}

Чому any шкідливий: він «заразний». Значення, отримане з any, теж any, і відсутність перевірок непомітно розповзається кодом.

Коли що:

  • unknown - для даних ззовні: JSON.parse, відповідь API, catch (error) (у строгому режимі error і так unknown), значення з localStorage. Звужуєте перевірками або схемою валідації (Zod).
  • any - як тимчасовий захід при міграції з JavaScript, з коментарем і планом прибрати.

У tsconfig варто тримати "strict": true (він вмикає noImplicitAny), а лінтер налаштувати так, щоб явний any був помітним.

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

TypeScript виводить типи (type inference) з ініціалізації, повернених значень і контексту. Анотації потрібні не всюди - надлишкові лише засмічують код.

const title = 'Laravel Ukraine';            // string - виведено
const ids = [1, 2, 3];                      // number[]
const user = { id: 7, name: 'Оля' };        // { id: number; name: string }

function total(prices: number[]) {          // параметри - анотувати
  return prices.reduce((sum, p) => sum + p, 0);   // повернення number - виведено
}

Де анотація обов'язкова або корисна:

  • параметри функцій - TypeScript не знає, що в них передадуть (виняток - колбеки, де тип відомий з контексту: ids.map((id) => ...));
  • змінна без ініціалізатора: let current: User | null = null; - інакше тип буде лише null;
  • порожні колекції: const errors: string[] = []; - інакше never[] чи any[];
  • публічний API модуля - експортовані функції з явним типом повернення: помилка в реалізації буде помічена в самій функції, а не в місцях виклику, і зміна поведінки не змінить API непомітно;
  • коли виведений тип ширший за потрібний: const status: 'draft' | 'published' = 'draft' - щоб змінна не стала просто string для let.

Основні типи:

  • примітиви: string, number, boolean, bigint, symbol, null, undefined;
  • масиви: string[] або Array<string>;
  • об'єкти: { id: number; email?: string } (? - необов'язкове поле);
  • об'єднання: string | number;
  • функції: (value: string) => boolean.

Що не варто анотувати: очевидні локальні змінні (const count: number = 0) - це шум. Те саме з типом повернення простих внутрішніх функцій.

Корисна перевірка в редакторі: наведення курсору показує виведений тип. Якщо він any чи несподівано широкий - місце для анотації.

strict: true вмикає noImplicitAny: параметр без анотації, тип якого не вдалося вивести, стане помилкою, а не тихим any. З TypeScript 6.0 strict увімкнено за замовчуванням - у старих проєктах його варто перевірити явно в tsconfig.

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

Задача та сама - обмежити значення фіксованим набором. Підходи різні.

enum - конструкція TypeScript, яка генерує JavaScript-код (об'єкт під час виконання):

enum Status {
  Draft = 'draft',
  Published = 'published',
}

function publish(status: Status) {}
publish(Status.Published);
publish('published');   // помилка: рядок не є Status

Об'єднання літеральних типів - лише тип, після компіляції зникає повністю:

type Status = 'draft' | 'published';

function publish(status: Status) {}
publish('published');   // ок

Аргументи на користь об'єднання:

  • нуль коду під час виконання - тип стирається;
  • сумісність зі звичайними рядками: значення з API ('published') одразу підходять, без перетворень;
  • сумісність з інструментами, що лише стирають типи: Node.js із вбудованим запуском TypeScript, erasableSyntaxOnly, швидкі транспілятори. enum - не «стиральний» синтаксис: його треба перетворювати на код, і з erasableSyntaxOnly компілятор його забороняє;
  • числові enum мають історичні дивацтва: зворотне відображення (Status[0] === 'Draft'), а у старих версіях TypeScript дозволяв присвоїти будь-яке число. З TS 5.0 присвоєння числа поза значеннями enum - помилка.

Коли потрібен ще й список значень (для <select>, валідації) - об'єкт з as const:

const STATUSES = ['draft', 'published', 'archived'] as const;
type Status = (typeof STATUSES)[number];   // 'draft' | 'published' | 'archived'

STATUSES.includes(value);   // перевірка під час виконання

Або об'єкт-«перелік»:

const Status = { Draft: 'draft', Published: 'published' } as const;
type Status = (typeof Status)[keyof typeof Status];

Один і той самий ідентифікатор - і значення, і тип: використання як в enum, але без генерації коду.

const enum вбудовує значення під час компіляції, але не працює з ізольованою транспіляцією (кожен файл окремо - Vite, esbuild) і має проблеми з бібліотеками.

Практична рекомендація: для нового коду - об'єднання літералів або as const-об'єкти. enum - якщо так прийнято в проєкті чи потрібна сумісність з наявним кодом.

Зв'язок з Laravel: PHP-енуми (enum Status: string) зручно генерувати у TypeScript як об'єднання літералів - значення збігаються з тими, що приходять у JSON.

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

Без strictNullChecks значення null і undefined можна присвоїти будь-якому типу - і TypeScript не попереджає про найчастішу помилку JavaScript: Cannot read properties of undefined.

З strictNullChecks (входить у strict: true) null і undefined - окремі типи, і їх треба явно дозволити:

let name: string = null;            // помилка
let middleName: string | null = null;   // ок

function findUser(id: number): User | undefined {
  return users.find((u) => u.id === id);
}

const user = findUser(5);
user.name;                          // помилка: user може бути undefined

Як працювати з можливою відсутністю значення:

// перевірка - TypeScript звужує тип
if (user) {
  user.name;                        // User
}

// опціональний ланцюжок
const city = user?.address?.city;   // string | undefined

// значення за замовчуванням
const displayName = user?.name ?? 'Гість';

// ранній вихід
if (!user) throw new Error('Користувача не знайдено');
user.name;                          // далі - User

Необов'язкові властивості (email?: string) мають тип string | undefined. Перед використанням - перевірка.

null чи undefined? У JavaScript обидва означають «немає значення»:

  • undefined - «не задано» (необов'язкові параметри, відсутні поля, результат find);
  • null - «навмисно порожньо» (часто з бази й API: Laravel серіалізує NULL колонки як null у JSON).

Корисно домовитися в проєкті: наприклад, undefined у власному коді, а null - там, де так приходить з сервера.

Оператор ! (non-null assertion) - user!.name - каже компілятору «повір, тут не null». Перевірки під час виконання немає: якщо твердження хибне, помилка повернеться. Використовувати лише там, де гарантію справді дає щось поза системою типів, і краще з коментарем.

Чому це найцінніша опція strict: вона змушує обробити випадки «не знайдено», «ще не завантажено», «поле не заповнене» - саме там, де найчастіше падають застосунки.

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

string[] і Array<string> - одне й те саме, два записи одного типу. Короткий запис зазвичай читається простіше; узагальнений зручніший для складних типів елементів:

const tags: string[] = ['php', 'vue'];
const handlers: Array<() => void> = [];   // замість (() => void)[]

Readonly-масиви забороняють змінювати масив через це посилання:

function sum(values: readonly number[]): number {
  values.push(1);      // помилка: push немає в readonly number[]
  values[0] = 5;       // помилка
  return values.reduce((a, b) => a + b, 0);   // читання - можна
}

readonly number[] і ReadonlyArray<number> - знову два записи одного типу. Методи, що змінюють масив (push, pop, splice, sort, reverse), у ньому відсутні; немутуючі (map, filter, toSorted) - доступні.

Навіщо:

  • контракт функції: параметр readonly T[] повідомляє, що функція масив не змінить. Викликач може спокійно передати свій стан;
  • ширша сумісність: у readonly number[] можна передати і звичайний масив, і readonly. Функція з параметром number[] не прийме readonly-масив. Тому для параметрів, які лише читаються, readonly - кращий вибір;
  • стан у React/Vue/Redux: заборона випадкової мутації на рівні типів.

as const робить масив літералів readonly-кортежем:

const sizes = ['sm', 'md', 'lg'] as const;   // readonly ['sm', 'md', 'lg']
type Size = (typeof sizes)[number];          // 'sm' | 'md' | 'lg'

Обмеження:

  • лише на рівні типів: під час виконання це звичайний масив. Хтось із посиланням number[] чи через as може його змінити. Для справжньої незмінності - Object.freeze;
  • поверхнево: readonly User[] забороняє змінювати масив, але не об'єкти в ньому - users[0].name = 'x' пройде. Для глибокої незмінності - ReadonlyArray<Readonly<User>> або власний тип DeepReadonly;
  • присвоєння readonly-масиву в змінну типу T[] - помилка; інколи це змушує додавати readonly у сигнатури по всьому ланцюжку викликів (і це правильно).

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