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

Senior: питання на співбесіді з теми «TypeScript та інструменти»

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

4 питання

Компоненти на кшталт списку, таблиці чи випадного списку працюють з даними будь-якого типу. Без узагальнень доводиться писати any - і зв'язок між даними та колбеками губиться.

Узагальнений компонент:

type ListProps<T> = {
  items: T[];
  getKey: (item: T) => string | number;
  renderItem: (item: T) => ReactNode;
  onSelect?: (item: T) => void;
};

function List<T>({ items, getKey, renderItem, onSelect }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={getKey(item)} onClick={() => onSelect?.(item)}>
          {renderItem(item)}
        </li>
      ))}
    </ul>
  );
}
<List
  items={users}                      // T = User - виведено з items
  getKey={(user) => user.id}
  renderItem={(user) => user.name}   // user: User
  onSelect={(user) => open(user.id)}
/>

TypeScript виводить T з переданих даних, і всі колбеки отримують правильний тип. Звернення до неіснуючого поля - помилка компіляції.

Обмеження типу параметра:

function Table<T extends { id: number }>({ rows, columns }: TableProps<T>) { /* ... */ }

Тепер можна використовувати row.id всередині й не передавати getKey.

Колонки, прив'язані до полів даних:

type Column<T> = {
  key: keyof T;
  header: string;
  render?: (value: T[keyof T], row: T) => ReactNode;
};

key: keyof T дозволяє лише існуючі поля - перейменування поля в моделі одразу підсвітить усі таблиці, де воно використовується.

Синтаксис у .tsx: стрілкова функція const List = <T,>(props: ListProps<T>) => ... потребує коми після T - інакше <T> прочитається як JSX-тег. Оголошення function List<T>(...) цієї проблеми не має.

Явний параметр можна передати в JSX, якщо виведення неможливе: <List<User> items={[]} ... />.

Пастки:

  • memo чи forwardRef губить узагальнення: memo(List) повертає компонент з T = unknown. Рішення - приведення типу (memo(List) as typeof List) або відмова від обгортки (з React Compiler потреба в ручному memo зменшується);
  • надто складні узагальнення (умовні типи, кілька параметрів) погіршують повідомлення про помилки - для команди простіший тип часто корисніший за максимально точний.

Докладніше в документації: Узагальнені типи (Generics)

Звичайні необов'язкові props дозволяють будь-які комбінації, навіть безглузді:

type ButtonProps = {
  href?: string;          // посилання
  onClick?: () => void;   // кнопка
  external?: boolean;     // має сенс лише з href
};

<Button onClick={save} external />   // компілюється, хоча безглуздо
<Button />                            // ні дії, ні посилання

Дискримінований union описує кожен допустимий варіант окремо:

type LinkProps = { as: 'link'; href: string; external?: boolean };
type ActionProps = { as: 'button'; onClick: () => void; disabled?: boolean };

type ButtonProps = (LinkProps | ActionProps) & { children: ReactNode };

function Button(props: ButtonProps) {
  if (props.as === 'link') {
    return (
      <a href={props.href} target={props.external ? '_blank' : undefined}>
        {props.children}
      </a>
    );
  }
  return (
    <button onClick={props.onClick} disabled={props.disabled}>
      {props.children}
    </button>
  );
}
<Button as="link" href="/jobs">Вакансії</Button>
<Button as="button" onClick={save}>Зберегти</Button>
<Button as="button" href="/x">...</Button>   // помилка: href не існує для button

Варіант без явного дискримінатора - через never, щоб props взаємно виключали один одного:

type Props =
  | { value: string; defaultValue?: never; onChange: (v: string) => void }   // керований
  | { defaultValue?: string; value?: never; onChange?: never };              // некерований

Передати одночасно value і defaultValue неможливо - типова помилка з полями форм.

Типові застосування:

  • керований / некерований компонент;
  • компонент стану запиту: { status: 'loading' } | { status: 'error'; error: string } | { status: 'success'; data: T };
  • модальне вікно з різними наборами кнопок;
  • поле форми, де options обов'язкові лише для типу select.

Пастки:

  • деструктуризація в параметрах губить звуження: function Button({ as, href, onClick }) - href не існує у варіанті button, TypeScript не дасть деструктуризувати. Звужувати треба по props.as, а деструктуризувати вже всередині гілки;
  • ...rest з union дає перетин полів і може «загубити» варіант - передаючи props далі, краще явно перелічувати;
  • повідомлення про помилки для великих union стають довгими - варто тримати варіантів небагато й давати їм імена.

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

Створення проєкту:

npm create vite@latest my-app -- --template react-ts

Шаблон містить vite.config.ts з плагіном @vitejs/plugin-react, tsconfig для коду застосунку й окремо для конфігурації, ESLint з eslint-plugin-react-hooks.

Що робить @vitejs/plugin-react:

  • перетворює JSX (автоматичний runtime - імпорт React у файлах не потрібен);
  • Fast Refresh - зміна компонента оновлює лише його, зі збереженням стану. Працює, якщо файл експортує лише компоненти: експорт констант чи функцій поряд з компонентом змушує перезавантажувати модуль повністю;
  • у Vite 8 перетворення робить швидкий компілятор на Rust (Oxc), Babel за замовчуванням не потрібен.

React Compiler у поточній версії плагіна підключається двома способами:

// 1. через Babel - стабільний варіант
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';

export default defineConfig({
  plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
});

// 2. нативний порт на Rust - експериментальний
export default defineConfig({
  plugins: [react({ compiler: true })],
});

Компілятор 1.0 стабільний, працює з React 17+ і додає мемоізацію на етапі збирання. Для поступового впровадження - compilationMode: 'annotation' (компілюються лише функції з директивою "use memo").

TypeScript важливо розуміти правильно:

  • Vite не перевіряє типи - він лише видаляє їх під час перетворення. Збирання пройде навіть з помилками типів;
  • перевірка - окремим кроком: tsc -b у скрипті build (так у шаблоні) і в CI, а під час розробки - редактор чи vite-plugin-checker;
  • "jsx": "react-jsx", "moduleResolution": "bundler", "strict": true, "noUncheckedIndexedAccess" - корисні налаштування для нового проєкту;
  • "isolatedModules": true потрібен, бо кожен файл перетворюється окремо: export type для реекспорту типів.

Змінні оточення - лише з префіксом VITE_, через import.meta.env; типи - в vite-env.d.ts.

ESLint з eslint-plugin-react-hooks (конфігурація recommended) у версії 7 містить і правила хуків, і правила React Compiler - порушення, через які компілятор пропустив би компонент, видно ще в редакторі.

З Laravel - laravel-vite-plugin поряд з плагіном React, сторінки через Inertia; стартовий набір Laravel React вже містить це налаштування.

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

eslint-plugin-react-hooks - офіційний плагін ESLint від команди React. З версії 6-7 він містить не лише два класичні правила хуків, а й правила, побудовані на аналізі React Compiler.

// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';

export default defineConfig([
  reactHooks.configs.flat.recommended,
]);

Класичні правила:

  • rules-of-hooks - хуки лише на верхньому рівні компонентів і власних хуків, не в умовах і циклах;
  • exhaustive-deps - усі значення, використані в useEffect/useMemo/useCallback, мають бути в залежностях.

Правила компілятора (у конфігурації recommended) ловлять порушення правил React, через які компонент працюватиме неправильно або компілятор його пропустить:

  • purity - нечисті виклики під час рендеру (Math.random(), Date.now() у тілі компонента);
  • immutability - мутація props, стану чи значень з хуків;
  • refs - читання чи запис ref.current під час рендеру;
  • set-state-in-render і set-state-in-effect - виклик setState прямо в рендері (нескінченний цикл) чи синхронно в ефекті (зайвий рендер; часто стан можна обчислити під час рендеру);
  • static-components - оголошення компонента всередині іншого компонента (він перестворюється щоразу й губить стан);
  • globals - зміна глобальних змінних під час рендеру;
  • preserve-manual-memoization - ручний useMemo/useCallback, який компілятор не може зберегти;
  • incompatible-library - бібліотеки з API, несумісним з мемоізацією компілятора;
  • error-boundaries, use-memo, unsupported-syntax, config, gating.

Конфігурація recommended-latest додає експериментальні правила.

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

Як упроваджувати в старий проєкт:

  • увімкнути правила й подивитися кількість порушень - це оцінка, наскільки код готовий до компілятора;
  • виправляти поступово, починаючи з purity, immutability, refs - вони вказують на реальні помилки;
  • не вимикати правило коментарем без пояснення: eslint-disable для exhaustive-deps - найчастіше маскування справжньої проблеми із застарілими значеннями (stale closure).

Докладніше в документації: eslint-plugin-react-hooks