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

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

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

16 питань

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

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

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

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