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

Питання на співбесіді з TypeScript

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

100 питань

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

class Model {
  save() { /* загальна логіка збереження */ }
}

class Post extends Model {
  title = '';
}

new Post().save();   // метод успадковано

Клас може успадковувати лише один клас.

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

interface Cacheable {
  cacheKey(): string;
}

interface Serializable {
  toJSON(): object;
}

class Product implements Cacheable, Serializable {
  constructor(private id: number) {}

  cacheKey() { return `product:${this.id}`; }
  toJSON() { return { id: this.id }; }
}

Клас може реалізовувати кілька інтерфейсів.

Важливе непорозуміння: implements не змінює тип класу і не впливає на виведення типів його членів. Він лише перевіряє:

interface Checkable {
  check(name: string): boolean;
}

class NameChecker implements Checkable {
  check(s) {   // s - неявно any, а не string з інтерфейсу!
    return s.length > 0;
  }
}

Параметр не отримує тип з інтерфейсу - його треба оголосити явно.

Структурна типізація: клас, який має потрібні методи, і так сумісний з інтерфейсом - навіть без implements. Ключове слово корисне як явний контракт: помилка з'являється в місці оголошення класу, а не там, де його десь передають.

Що обирати:

  • extends - коли є спільна реалізація, а нащадок справді «є різновидом» батька;
  • implements - коли потрібен спільний контракт без спільного коду;
  • для повторного використання поведінки часто краща композиція (передати об'єкт-помічник), ніж глибокі ієрархії успадкування.

Інтерфейс може «розширювати» клас (interface X extends SomeClass) - береться лише його форма, разом із приватними членами, що на практиці трапляється рідко.

Докладніше в документації: Класи: implements

TypeScript використовує синтаксис ES-модулів: файл з import чи export на верхньому рівні - модуль зі своєю областю видимості.

// money.ts
export function formatPrice(amount: number): string {
  return `${amount.toFixed(2)} грн`;
}
export type Currency = 'UAH' | 'USD';

// app.ts
import { formatPrice, type Currency } from './money.js';

Файл без import/export - скрипт: його оголошення потрапляють у глобальну область. Тому в TypeScript-файлах інколи пишуть порожній export {} - щоб файл став модулем.

Чому .js в імпорті .ts-файлу. TypeScript не переписує шляхи імпортів при компіляції: що написано в коді, те й опиниться в зібраному JavaScript. Після компіляції money.ts стане money.js, і Node.js шукатиме саме ./money.js. Тому з moduleResolution: "nodenext" відносні імпорти пишуть з розширенням виконуваного файлу - .js, а TypeScript розуміє, що йдеться про money.ts.

Що залежить від налаштувань:

  • moduleResolution: "bundler" (Vite, webpack, esbuild) - розширення можна не писати: збирач сам знайде файл. Найзручніше для фронтенду;
  • moduleResolution: "nodenext" - правила Node.js: розширення обов'язкове для ES-модулів, а тип модуля (ESM чи CommonJS) визначається полем "type" у package.json або розширенням .mts/.cts;
  • запуск .ts напряму (стирання типів у Node.js, Bun, Deno) - можна імпортувати з .ts за allowImportingTsExtensions; для бібліотек, що компілюються, rewriteRelativeImportExtensions перепише .ts на .js у результаті.

TypeScript 6/7: старі режими moduleResolution: "node" (node10) і "classic" видалено - лишилися nodenext і bundler. Типове значення module - esnext.

Корисне правило: спосіб резолюції модулів у tsconfig має відповідати тому, хто реально виконує код - збирач, Node.js чи інший рушій. Інакше TypeScript погоджуватиметься з імпортами, які не працюють під час виконання.

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

import type імпортує лише тип - такий імпорт гарантовано зникає з JavaScript після компіляції.

import type { User } from './models.js';
import { fetchUser, type Role } from './api.js';   // змішаний: fetchUser - значення, Role - тип

export type { User };

Навіщо, якщо TypeScript і так прибирає невикористані в коді імпорти типів:

1. Інструменти, що працюють з одним файлом. Babel, esbuild, SWC, стирання типів у Node.js перетворюють кожен файл окремо, не знаючи, що в іншому файлі User - це інтерфейс, а не клас. Для рядка import { User } from './models.js' вони не можуть вирішити, чи лишати імпорт. import type знімає неоднозначність.

2. Помилки виконання. Якщо імпорт типу лишився в JavaScript, а модуль нічого з такою назвою не експортує (інтерфейси під час виконання не існують), ES-модуль впаде: «The requested module does not provide an export named 'User'».

3. Побічні ефекти й цикли. Звичайний імпорт завантажує модуль і виконує його код. import type - ні. Це розриває циклічні залежності, що існують лише на рівні типів, і не тягне важкі модулі заради одного типу.

4. Читабельність - видно, що з модуля потрібні лише типи.

Прапорець verbatimModuleSyntax робить це обов'язковим: імпорт без type, у якому лише типи, - помилка компіляції. Рекомендований для нових проєктів.

Пастки:

  • import type { Foo } не можна використати як значення - new Foo() чи Foo.staticMethod() дадуть помилку. Для класу, який використовується і як тип, і як значення, - звичайний імпорт;
  • import type X from (за замовчуванням) і import type * as ns from теж працюють;
  • різниця import type { A } і import { type A }: з verbatimModuleSyntax перший зникає повністю, а другий лишає import {} from './mod.js' - модуль усе одно завантажиться заради побічних ефектів.

Лінтер (@typescript-eslint/consistent-type-imports) автоматично виправляє імпорти на import type.

Докладніше в документації: Модулі: синтаксис TypeScript

Файл декларацій .d.ts описує лише типи - без реалізації. Він каже TypeScript, які функції, класи й змінні існують і якого вони типу, а сам код живе деінде (у JavaScript-файлі).

// slugify.d.ts
export default function slugify(text: string, options?: { lower?: boolean }): string;

Звідки беруться декларації:

1. Пакет сам постачає типи. Бібліотека, написана на TypeScript, генерує .d.ts при збиранні (declaration: true) і вказує їх у package.json. Більшість сучасних пакетів так і роблять - нічого встановлювати не треба.

2. Пакети @types/* - для бібліотек, написаних на JavaScript без власних типів. Їх пишуть і підтримують спільнотою в репозиторії DefinitelyTyped:

npm install -D @types/lodash

Версія @types/lodash повторює мажорну й мінорну версію самої бібліотеки - їх варто тримати узгодженими.

3. Вбудовані декларації TypeScript - lib.dom.d.ts, lib.es2025.d.ts тощо. Набір обирається опціями target і lib.

4. Власні декларації в проєкті - для бібліотек без типів, глобальних змінних, файлів-ресурсів (*.svg, *.css).

Як TypeScript знаходить типи імпорту: спершу в самому пакеті (types/exports у package.json), потім у node_modules/@types/назва-пакета.

Важлива зміна в TypeScript 6/7: опція types за замовчуванням тепер порожня ([]). Раніше TypeScript автоматично підключав усі пакети з node_modules/@types як глобальні - через це в кожному проєкті були типи process, describe тощо. Тепер глобальні типи треба перелічити явно:

{ "compilerOptions": { "types": ["node", "vitest/globals"] } }

На типи пакетів, які імпортуються (import _ from 'lodash'), це не впливає - лише на глобальні.

skipLibCheck: true - не перевіряти .d.ts залежностей: значно прискорює збирання, а помилки в чужих деклараціях вам однаково не виправити.

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

Типовий симптом після оновлення: десятки помилок «Cannot find name 'process'», «Cannot find name 'describe'», «Cannot find module 'fs'».

Причина - нове значення опції types за замовчуванням.

  • До TypeScript 6: якщо types не вказано, компілятор автоматично підключав усі пакети з node_modules/@types як глобальні декларації. Встановлено @types/node - і process доступний будь-де.
  • TypeScript 6 і 7: types за замовчуванням - порожній масив. Жоден пакет @types не підключається глобально сам по собі.

Рішення - перелічити потрібні глобальні типи явно:

{
  "compilerOptions": {
    "types": ["node", "jest"]
  }
}

Або для різних частин проєкту - різні tsconfig (тести бачать vitest/globals, а код застосунку - ні).

Повернути стару поведінку можна значенням "types": ["*"], але команда TypeScript радить явний список.

Навіщо це змінили:

  • швидкість: у сучасних репозиторіях node_modules/@types містить сотні пакетів, підтягнутих транзитивно. Їх розбір і перевірка займали помітну частину збирання - за даними команди TypeScript, явний types прискорював збирання багатьох проєктів на 20-50%;
  • передбачуваність: глобальні типи тестового фреймворку не «протікають» у код застосунку (у браузерному коді раптом доступний describe чи process).

На що це НЕ впливає: на типи пакетів, які ви імпортуєте. import express from 'express' знайде @types/express як і раніше. Опція types стосується лише глобальних оголошень.

Інші зміни TypeScript 6/7, що дають схожі «раптові» помилки:

  • rootDir тепер за замовчуванням - каталог з tsconfig.json. Якщо код у src/, а результат раптом з'являється в dist/src/, треба вказати "rootDir": "./src";
  • strict: true за замовчуванням - проєкти, що покладалися на нестрогий режим, мають явно вказати "strict": false (краще - виправити помилки).

Порада: оновлюватися через TypeScript 6 - він показує попередження про застарілі налаштування, а TypeScript 7 робить їх помилками.

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

Збирачі (Vite, webpack) дозволяють імпортувати не лише код: import logo from './logo.svg'. TypeScript про такі файли нічого не знає і повідомляє «Cannot find module './logo.svg'».

Рішення - ambient-оголошення модуля з шаблоном:

// src/assets.d.ts
declare module '*.svg' {
  const src: string;
  export default src;
}

declare module '*.module.css' {
  const classes: Readonly<Record<string, string>>;
  export default classes;
}

Тепер будь-який імпорт, що закінчується на .svg, має тип рядка (URL файлу).

Vite вже має такі оголошення - досить підключити їх: "types": ["vite/client"] у tsconfig або /// <reference types="vite/client" /> у файлі vite-env.d.ts. Там описано і ?url, ?raw, ?inline, і import.meta.env.

Чому declare module '*.svg' інколи «не працює». Найчастіша причина - файл з оголошенням є модулем: у ньому є import чи export на верхньому рівні.

// assets.d.ts - НЕ спрацює
import type { FC } from 'react';
declare module '*.svg' {
  const Component: FC;
  export default Component;
}

У файлі-модулі declare module 'назва' - це доповнення (augmentation) існуючого модуля, а доповнити шаблон, якого немає, неможливо. Ambient-оголошення нових модулів мають бути у скрипті - .d.ts без імпортів на верхньому рівні. Якщо тип потрібен з іншого пакета - імпорт всередині блоку:

declare module '*.svg?react' {
  import type { FC, SVGProps } from 'react';
  const Component: FC<SVGProps<SVGSVGElement>>;
  export default Component;
}

Інші причини:

  • файл з оголошеннями не входить у include чи files у tsconfig;
  • шаблон не збігається ('*.svg' проти імпорту з ?react на кінці).

Обережно з «заглушками»: declare module 'some-lib'; без тіла робить усі імпорти з пакета типу any - це прибирає помилку, але й будь-яку перевірку. Для бібліотек краще знайти чи написати справжні типи.

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

tsconfig.json у корені проєкту каже компілятору TypeScript, які файли перевіряти і як це робити. Каталог з цим файлом вважається коренем проєкту.

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "types": ["vite/client"],
    "paths": { "@/*": ["./resources/js/*"] }
  },
  "include": ["resources/js/**/*.ts", "resources/js/**/*.vue"],
  "exclude": ["node_modules", "public"]
}

Основні розділи:

  • include / exclude / files - які файли входять у проєкт. include приймає шаблони, exclude відкидає частину з них, files - явний список;
  • compilerOptions - як перевіряти й що генерувати: строгість, цільова версія JavaScript, система модулів, шляхи, глобальні типи;
  • extends - успадкувати базовий конфіг (свій спільний або з пакета на кшталт @tsconfig/strictest, @vue/tsconfig);
  • references - посилання на інші проєкти в монорепозиторії.

Важливо розуміти, хто читає tsconfig.json:

  • tsc - повністю;
  • редактор (мовний сервер TypeScript) - для підказок і помилок;
  • збирачі (Vite, esbuild) - лише частину опцій, і не перевіряють типи;
  • Node.js з вбудованим зняттям типів - не читає взагалі.

noEmit: true - типова опція для застосунків на Vite: JavaScript генерує збирач, а tsc лише перевіряє типи.

Нові значення за замовчуванням (TypeScript 6.0+ і 7.0): strict увімкнено, module - esnext, target - останній стабільний стандарт ECMAScript, types - порожній список. Порожній tsconfig.json сьогодні - вже доволі строга конфігурація.

Практична порада: не копіювати чужий конфіг цілком, а починати з базового пресету фреймворку й змінювати лише те, що розумієте, - багато «магічних» опцій зі старих статей у TypeScript 7 уже видалено.

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

strict - не одна перевірка, а набір строгих опцій, увімкнених разом:

  • noImplicitAny - помилка, якщо тип не можна вивести і він став би any (параметри функцій без типів);
  • strictNullChecks - null і undefined - окремі типи. string не може бути null, і компілятор змушує перевірити значення перед використанням. Найважливіша з усіх;
  • strictFunctionTypes - коректна (контраваріантна) перевірка параметрів функцій;
  • strictBindCallApply - перевірка аргументів bind, call, apply;
  • strictPropertyInitialization - поля класу мають бути ініціалізовані в конструкторі;
  • noImplicitThis - помилка на this з неявним типом any;
  • useUnknownInCatchVariables - змінна в catch має тип unknown, а не any;
  • alwaysStrict - режим 'use strict' у кожному файлі (у TypeScript 7 його вже не можна вимкнути).
function greet(user: { name: string } | null) {
  return user.name;   // з strictNullChecks: помилка 'user' is possibly 'null'
}

З TypeScript 6.0 strict увімкнено за замовчуванням. Проєкт, що покладався на старе значення false, має явно вказати "strict": false - і це сигнал, що варто планувати перехід.

Чому не вимикати:

  • без strictNullChecks TypeScript мовчить про найпоширеніші помилки виконання - Cannot read properties of undefined;
  • без noImplicitAny значна частина коду фактично не типізована;
  • нові опції строгості з'являються в наборі з новими версіями - проєкт зі strict: true отримує їх автоматично.

Міграція великого проєкту, де одразу тисячі помилок:

  • вмикати опції по одній (strict: false + окремі прапорці), починаючи з noImplicitAny і strictNullChecks;
  • або вмикати strict для нових каталогів через окремі конфіги чи інструменти поступової міграції.

Понад strict корисні noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride, noFallthroughCasesInSwitch - вони в набір strict не входять і вмикаються окремо.

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

Ці три опції часто плутають, хоча вони відповідають на різні питання.

target - у яку версію JavaScript перетворювати синтаксис.

const user = data?.user ?? guest;

З target: "es2019" компілятор перепише ?. і ?? старим синтаксисом, з es2020 і новіше - лишить як є. target змінює лише синтаксис, а не додає відсутні функції (поліфіли).

У TypeScript 6.0+ значення за замовчуванням - останній стабільний стандарт (зараз es2025), а es5 у TypeScript 7 видалено - найнижча ціль тепер es2015. Для старих браузерів TypeScript-код компілюють іншим інструментом.

lib - які вбудовані API існують у середовищі, для перевірки типів:

"lib": ["es2025", "dom", "dom.iterable"]

Без dom компілятор не знає про document і window; без свіжого es20xx - про Array.prototype.toSorted чи Object.groupBy. Якщо lib не вказано, він береться з target (плюс dom). Для коду в Node.js dom зайвий, натомість потрібен пакет @types/node.

Пастка: lib лише описує типи. Вказати es2025, а запускати код у браузері, який цих методів не має, - помилка виконання, яку TypeScript не впіймає.

module - яку систему модулів генерувати (і як трактувати import/export):

  • esnext / preserve - лишити ES-модулі (типово для коду, який збирає Vite);
  • nodenext - як Node.js: ES-модулі чи CommonJS залежно від "type" у package.json і розширень файлів;
  • commonjs - require / module.exports.

amd, umd, systemjs і none у TypeScript 7 вже не підтримуються.

Пов'язана опція moduleResolution - як знаходити файли за шляхом в import: bundler для Vite, nodenext для коду, що виконує Node.js.

Для типового Vite-застосунку: target і module мало впливають на результат (код генерує збирач), але lib визначає, які API бачить редактор, - його варто узгодити з браузерами, які ви підтримуєте.

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

Замість import Button from '../../../components/Button.vue' зручно писати import Button from '@/components/Button.vue'. Для цього налаштовують псевдоніми шляхів.

У tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./resources/js/*"]
    }
  }
}

Важлива зміна: раніше разом з paths вказували baseUrl. У TypeScript 6.0 його оголосили застарілим, а в TypeScript 7 - видалено (помилка «Option 'baseUrl' has been removed»). Шляхи в paths тепер пишуть відносно каталогу з tsconfig.json, з явним префіксом ./.

Чому однієї опції paths замало: вона лише каже компілятору, де шукати типи. Код вона не змінює - у зібраному JavaScript лишиться import ... from '@/components/Button.vue', і хтось має перетворити цей шлях на справжній.

Тому той самий псевдонім треба налаштувати в інструменті, що виконує чи збирає код:

  • Vite - resolve.alias у vite.config.ts (у Laravel-проєктах @ часто вже налаштовано плагіном чи шаблоном стартового набору):
import { fileURLToPath, URL } from 'node:url';

export default defineConfig({
  resolve: {
    alias: { '@': fileURLToPath(new URL('./resources/js', import.meta.url)) },
  },
});
  • Vitest - бере resolve.alias з конфігурації Vite;
  • Node.js з вбудованим запуском TypeScript - не читає tsconfig.json і paths не підтримує. Альтернатива - subpath imports у package.json ("imports": { "#/*": "./src/*" }), які розуміють і Node.js, і TypeScript.

Типові помилки:

  • псевдонім є в tsconfig.json, але не у збирачі - редактор задоволений, а збірка падає з «Failed to resolve import»;
  • різні значення в двох місцях - редактор показує один файл, а в зібраному коді опиняється інший;
  • paths у бібліотеці, яку публікують на npm, - у споживачів ці шляхи не працюватимуть; у пакетах краще imports/exports з package.json.

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

Глобальні змінні середовища - process, Buffer, describe, it, expect - TypeScript знає не сам, а з пакетів типів: @types/node, @types/jest тощо. Опція types визначає, які з таких пакетів підключати глобально, без явного імпорту.

Що змінилося. До TypeScript 5.9 включно значення за замовчуванням було «усі пакети з node_modules/@types». У великих проєктах це сотні пакетів, підтягнутих транзитивно, - повільна перевірка й випадкові глобальні типи.

З TypeScript 6.0 (і в 7.0) types за замовчуванням - порожній список []. Після оновлення проєкт, що покладався на автоматичне підключення, бачить:

error TS2591: Cannot find name 'process'. Do you need to install type definitions for node?
Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.

Рішення - перелічити потрібні пакети явно:

{
  "compilerOptions": {
    "types": ["node", "vite/client"]
  }
}
  • node - для коду, що працює в Node.js (конфіги, скрипти, SSR);
  • vite/client - типи import.meta.env, import.meta.hot і імпорту ресурсів (.svg, .css) у Vite-застосунку;
  • vitest/globals - якщо тести використовують глобальні describe/it.

Повернути стару поведінку можна значенням ["*"], але документація TypeScript радить явний список: за їхніми даними, багато проєктів пришвидшили збірку на 20-50% лише завдяки цьому.

Що варто знати:

  • types стосується лише глобальних оголошень. Пакети, які ви імпортуєте (import express from 'express'), підключаються через імпорт і не потребують запису в types;
  • різні частини проєкту - різні глобальні типи: код браузера не повинен бачити process, а конфіги Vite - document. Тому шаблони Vite мають два конфіги (tsconfig.app.json і tsconfig.node.json) з різними types і lib;
  • пакет має бути встановлений: запис у types без @types/node у devDependencies дасть помилку «Cannot find type definition file».

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

Типи TypeScript існують лише під час компіляції. Після збирання від них не лишається нічого - браузер виконує звичайний JavaScript. Тому TypeScript перевіряє ваш код, але не дані, що приходять ззовні.

type User = { id: number; name: string; email: string };

const response = await fetch('/api/user');
const user: User = await response.json();   // response.json() повертає any

user.email.toLowerCase();   // компілюється, а якщо API повернув { data: {...} } - падає

Анотація : User - це обіцянка розробника, а не перевірка. Якщо бекенд змінив формат, перейменував поле чи повернув null, TypeScript про це не дізнається - помилка вилізе під час виконання, часто далеко від місця отримання даних.

Звідки беруться «неперевірені» дані:

  • відповіді API (response.json() має тип Promise<any>);
  • JSON.parse (теж any);
  • localStorage, параметри URL, postMessage, WebSocket;
  • змінні оточення, значення полів форм.

Як захиститися - перевірка під час виконання на межі системи:

import * as z from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.email(),
});

type User = z.infer<typeof UserSchema>;   // тип виводиться зі схеми

const user = UserSchema.parse(await response.json());   // кине помилку, якщо дані не такі

Схема і тип - одне джерело правди: змінили схему - змінився тип.

Правила:

  • дані ззовні мають тип unknown, доки не перевірені - а не any;
  • перевіряти один раз на межі (в API-клієнті), а далі код працює з надійно типізованими даними;
  • помилка перевірки має бути помітною: зрозуміле повідомлення, лог у моніторинг - так розбіжність між бекендом і фронтендом виявляють одразу.

Альтернативи Zod: Valibot (менший розмір у збірці), ArkType, TypeBox. Ідея однакова - схема під час виконання, з якої виводиться тип.

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

У JavaScript throw може кинути що завгодно - не лише Error, а й рядок, число, об'єкт чи undefined. Сторонні бібліотеки й старий код справді так роблять. Тому TypeScript не може гарантувати, що в catch прийде Error.

З strict (опція useUnknownInCatchVariables) змінна в catch має тип unknown:

try {
  await saveOrder(order);
} catch (error) {
  console.log(error.message);   // помилка: 'error' is of type 'unknown'
}

Звуження перед використанням:

try {
  await saveOrder(order);
} catch (error) {
  if (error instanceof ValidationError) {
    showFieldErrors(error.errors);
  } else if (error instanceof Error) {
    showToast(error.message);
  } else {
    showToast('Невідома помилка');
    report(error);
  }
}

Допоміжна функція для повідомлення:

function errorMessage(error: unknown): string {
  if (error instanceof Error) return error.message;
  if (typeof error === 'string') return error;
  return 'Невідома помилка';
}

Чому не catch (error: any): це повертає стару небезпечну поведінку - error.response.data.message компілюється й падає з TypeError, якщо помилка мережева й response немає. Явна анотація catch (error: Error) не дозволена - TypeScript не може цього гарантувати.

Пастки з instanceof:

  • помилки з іншого вікна (iframe) чи іншої копії бібліотеки в збірці не проходять instanceof - для них перевіряють name чи наявність полів;
  • власні класи помилок мають правильно наслідувати Error (class HttpError extends Error) і задавати name.

Помилки з fetch: fetch не кидає винятку на 404 чи 500 - лише на мережеві помилки. Перевірку response.ok і перетворення на власну помилку (HttpError зі статусом) робить ваш API-клієнт - тоді в catch вона розпізнається через instanceof.

Відхилені проміси - пастка: на параметр колбеку .catch((error) => ...) опція useUnknownInCatchVariables не поширюється, і він має тип any. Його варто явно анотувати: .catch((error: unknown) => ...) - а правило лінтера @typescript-eslint/use-unknown-in-catch-callback-variable нагадає про це. З async/await і try/catch проблеми немає.

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

Vite передає в клієнтський код змінні оточення з префіксом VITE_ через import.meta.env. За замовчуванням TypeScript знає лише вбудовані поля (MODE, DEV, PROD, BASE_URL, SSR), а власні змінні мають тип any чи не існують.

Підключити типи Vite - у tsconfig.json:

{ "compilerOptions": { "types": ["vite/client"] } }

(з TypeScript 6.0 types за замовчуванням порожній - без цього навіть import.meta.env невідомий).

Описати власні змінні - файл resources/js/env.d.ts (чи src/vite-env.d.ts):

/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_APP_NAME: string;
  readonly VITE_REVERB_APP_KEY: string;
  readonly VITE_REVERB_PORT?: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Тепер import.meta.env.VITE_APP_NAME - string, редактор підказує назви, а друкарська помилка VITE_APP_NAM дасть помилку компіляції.

Важливо: тип - не гарантія наявності. Оголошення string не означає, що змінна справді задана в .env на сервері збирання. Відсутня змінна буде undefined в зібраному коді. Надійніше перевірити при старті застосунку:

import * as z from 'zod';

export const env = z
  .object({
    VITE_APP_NAME: z.string().min(1),
    VITE_REVERB_PORT: z.coerce.number().default(443),
  })
  .parse(import.meta.env);

Неправильна конфігурація виявляється одразу з зрозумілим повідомленням, а не дивною поведінкою в продакшені. Заодно рядкові значення перетворюються на числа й булеві.

Що варто пам'ятати:

  • усі значення з .env - рядки: VITE_FEATURE_X=false дає рядок 'false', який у if - істина;
  • змінні з префіксом VITE_ потрапляють у зібраний JavaScript і видні будь-кому - секрети туди не кладуть;
  • значення підставляються під час збирання: зміна .env на сервері без перезбирання нічого не змінить.

У Node.js-коді (конфіги, SSR) - process.env з типами з @types/node і така сама перевірка схемою.

Докладніше в документації: Vite: IntelliSense для TypeScript

Усі три описують «відповідність ключів значенням», але з різною точністю.

Індексна сигнатура - об'єкт з довільними ключами певного типу:

interface Prices {
  [sku: string]: number;
}

Record<K, V> - те саме коротше, але з важливою можливістю: ключі можуть бути обмеженим набором:

type Prices = Record<string, number>;                  // як індексна сигнатура

type Labels = Record<'draft' | 'published' | 'archived', string>;
const labels: Labels = {
  draft: 'Чернетка',
  published: 'Опубліковано',
  archived: 'В архіві',   // пропустити ключ - помилка
};

З обмеженим набором ключів TypeScript вимагає всі ключі - додали новий статус у тип, і компілятор покаже кожен словник, де бракує перекладу. Це дуже корисно для мап статусів, перекладів, конфігурацій.

Map<K, V> - окрема структура даних під час виконання, а не тип об'єкта:

const cache = new Map<number, User>();
cache.set(user.id, user);
const cached = cache.get(5);   // User | undefined

Коли що:

Звичайний об'єкт (Record) Map
ключі рядки (і символи) будь-які: числа, об'єкти
порядок цілочисельні ключі сортуються порядок додавання
JSON серіалізується {} - треба перетворювати
часте додавання й видалення повільніше оптимізовано
розмір Object.keys(o).length map.size

Пастки:

  • Record<string, V> бреше про наявність ключа: prices['unknown'] має тип number, хоча під час виконання - undefined. Рятує noUncheckedIndexedAccess (тип стає number | undefined) - у Map.get() це вбудовано;
  • ключі з даних користувача в звичайному об'єкті можуть зіткнутися з __proto__ чи constructor. Для довільних ключів - Map або Object.create(null);
  • числові ключі в об'єкті стають рядками: { 1: 'a' } має ключ '1'.

Правило: фіксований набір ключів - Record з об'єднанням; довільні ключі з даних, що часто змінюються, - Map; дані для JSON (відповіді API, конфіги) - об'єкти.

Докладніше в документації: Індексні сигнатури

Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 36 Middle 35 Senior 29

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії