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

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

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

5 питань

Generics - параметри типів: функція, клас чи тип працює з різними типами, зберігаючи зв'язок між ними. Без generics довелося б вибирати між дублюванням коду й any.

function first<T>(items: T[]): T | undefined {
  return items[0];
}

const n = first([1, 2, 3]);        // number | undefined
const s = first(['a', 'b']);       // string | undefined

TypeScript сам вивів T з аргументу - писати first<number>(...) не потрібно.

Обмеження extends - вимога до параметра типу:

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest('abc', 'de');       // працює з рядками
longest([1, 2], [3]);       // і з масивами
longest(10, 20);            // помилка: у number немає length

keyof разом з generics - типобезпечний доступ до полів:

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

pluck(users, 'email');      // string[]
pluck(users, 'emial');      // помилка компіляції: опечатка видна одразу

Де generics щодня: Array<T>, Promise<T>, Map<K, V>, хуки (useState<User | null>(null)), типізовані API-клієнти (get<User>('/users/1')).

Пастка: generic, який використовується лише раз (function log<T>(x: T): void), нічого не дає - тут достатньо unknown. Параметр типу має сенс, коли він пов'язує кілька місць: аргументи між собою або аргумент з результатом.

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

Звуження (narrowing) - TypeScript аналізує перевірки в коді й усередині гілки вважає тип вужчим, ніж оголошений.

function format(value: string | number | null) {
  if (value === null) return '-';
  if (typeof value === 'number') {
    return value.toFixed(2);   // тут value: number
  }
  return value.trim();         // а тут value: string
}

Що звужує тип:

  • typeof x === 'string', x instanceof Date, Array.isArray(x);
  • перевірки на null/undefined і на істинність;
  • 'field' in obj - чи є властивість;
  • порівняння з літералом (status === 'paid') - основа discriminated unions;
  • ранній return чи throw: після них тип уже звужено до кінця функції.

Власний type guard - функція з типом повернення value is T:

interface Cat { meow(): void }
interface Dog { bark(): void }

function isCat(animal: Cat | Dog): animal is Cat {
  return 'meow' in animal;
}

if (isCat(pet)) pet.meow();

Assertion function - кидає виняток, якщо умова не виконана, і звужує тип після виклику:

function assertDefined<T>(value: T): asserts value is NonNullable<T> {
  if (value == null) throw new Error('Значення відсутнє');
}

Пастка: type guard - це обіцянка, яку компілятор не перевіряє. Якщо isCat повертає true для собаки, TypeScript повірить. Тому логіка в guard має бути простою й надійною, а для даних ззовні краще схема валідації.

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

Кортеж - масив фіксованої структури: відомо, скільки елементів і якого типу кожен.

type Point = [number, number];
type Entry = [key: string, value: number];   // іменовані елементи

const p: Point = [10, 20];
const [x, y] = p;

Імена елементів (key, value) нічого не змінюють у поведінці - вони лише для читабельності й підказок редактора.

Де кортежі зустрічаються:

  • повернення кількох значень: useState в React повертає [value, setValue] - кортеж, тому деструктуризація дає правильні типи кожному елементу;
  • Object.entries, пари ключ-значення;
  • параметри функцій як кортеж: Parameters<typeof fn> - кортеж типів аргументів.

Необов'язкові елементи - лише в кінці:

type Range = [start: number, end?: number];
const r1: Range = [1];
const r2: Range = [1, 5];

Залишкові (rest) елементи - змінна довжина:

type Command = [name: string, ...args: string[]];
type WithFlag = [...items: number[], flag: boolean];   // rest може бути й не в кінці

Варіадичні кортежі - комбінування типів кортежів у узагальненнях:

function concat<A extends unknown[], B extends unknown[]>(a: [...A], b: [...B]): [...A, ...B] {
  return [...a, ...b];
}

const result = concat([1, 'a'], [true]);   // [number, string, boolean]

Так типізуються функції-обгортки, що додають аргументи на початок чи в кінець (bind, middleware, каррування).

Пастки:

  • виведення типу масиву - не кортеж: const pair = [1, 'a'] має тип (string | number)[]. Для кортежу - анотація або as const (тоді readonly [1, 'a']);
  • кортеж можна змінити через методи масиву: point.push(30) компілюється для звичайного кортежу. readonly [number, number] це забороняє;
  • довгі кортежі погано читаються: якщо елементів більше 2-3, краще об'єкт з іменованими полями - { lat, lng } замість [number, number].

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

Обидва способи складають тип з кількох частин:

interface HasId { id: number }
interface HasTimestamps { createdAt: string; updatedAt: string }

// успадкування інтерфейсів
interface Post extends HasId, HasTimestamps {
  title: string;
}

// перетин типів
type Comment = HasId & HasTimestamps & { body: string };

Для простих випадків результат однаковий. Різниця - у конфліктах і в тому, як компілятор з ними працює.

1. Конфлікт властивостей.

interface A { status: string }
interface B extends A { status: number }   // помилка одразу: number не сумісний з string

type C = { status: string } & { status: number };   // помилки немає...
// ...але C['status'] - це never: жодне значення не підійде, і об'єкт типу C не створити

extends повідомляє про несумісність в оголошенні. Перетин мовчки дає never, і помилка з'являється далеко - там, де намагаються створити об'єкт.

2. Продуктивність перевірки типів. Інтерфейси кешуються як іменовані типи; компілятор порівнює їх за назвою. Великі перетини перераховуються щоразу. Документація TypeScript з продуктивності радить у великих кодових базах складати об'єктні типи через interface ... extends, а не через довгі ланцюжки &.

3. Що можна скласти. extends в інтерфейсі - лише з об'єктними типами (й класами). Перетин працює з будь-якими типами, включно з об'єднаннями й узагальненнями:

type WithMeta<T> = T & { meta: { requestId: string } };
type Branded = string & { readonly __brand: 'UserId' };

Перетин з об'єднаннями розподіляється: (A | B) & C - це (A & C) | (B & C).

Перетин примітивів - string & number - дає never: значення, що водночас рядок і число, не існує.

Практичні правила:

  • об'єктні сутності й їх розширення - interface ... extends: краща діагностика й швидкість;
  • узагальнені «домішки» (T & { ... }), брендовані типи, композиція типів з утиліт - перетин;
  • якщо перетин дає несподіваний never, - майже завжди конфлікт однойменних властивостей.

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

void - функція нічого корисного не повертає; результат не слід використовувати:

function log(message: string): void {
  console.log(message);
}

Особливість: тип функції з void, наприклад () => void для колбеку, дозволяє передати функцію, що щось повертає. Результат просто ігнорується:

const ids: number[] = [];
[1, 2].forEach((n) => ids.push(n));   // push повертає number, але forEach очікує () => void - ок

Це навмисно: інакше багато звичайних колбеків довелося б обгортати.

undefined як тип повернення - функція явно повертає undefined, і результат можна використовувати як значення. З TS 5.1 функцію з типом undefined можна не закінчувати return.

never - функція ніколи не повертає керування:

function fail(message: string): never {
  throw new Error(message);
}

function loop(): never {
  while (true) {}
}

Після виклику такої функції код недосяжний, і TypeScript це враховує при звуженні:

const user = findUser(id) ?? fail('Користувача не знайдено');   // user: User, без undefined

never також - порожній тип: значення такого типу не існує. Тому його використовують для перевірки вичерпності (const _exhaustive: never = value) і він зникає з об'єднань: string | never - це string.

unknown - функція повертає щось, але тип невідомий: викликач має перевірити перед використанням. Правильний тип для JSON.parse, результатів сторонніх бібліотек без типів, catch (error).

Підсумок:

Тип Значення
void «результат не використовувати»
undefined «повертає саме undefined»
never «не повертає взагалі» (виняток, нескінченний цикл, вихід з процесу)
unknown «повертає щось, перевір перед використанням»

Пастка з асинхронністю: async функція, що нічого не повертає, має тип Promise<void>, а не void. Передача її туди, де очікується () => void (обробник події), дозволена, але відхилений проміс ніхто не обробить - лінтер no-misused-promises це помічає.

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