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

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

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

4 питання

Універсального способу немає - для різних типів різні інструменти.

typeof - для примітивів і функцій:

typeof 'x';         // 'string'
typeof 1;           // 'number' (і для NaN теж)
typeof 1n;          // 'bigint'
typeof undefined;   // 'undefined'
typeof Symbol();    // 'symbol'
typeof (() => {});  // 'function'
typeof null;        // 'object' - історична помилка
typeof [];          // 'object'

Масив: Array.isArray(value), а не typeof чи instanceof.

instanceof - чи є в ланцюжку прототипів конструктор: err instanceof TypeError. Пастка - різні realm'и: масив з iframe чи з іншого vm-контексту в Node.js має інший Array, і instanceof Array дає false. Array.isArray таку проблему не має.

Точний «внутрішній» тип:

Object.prototype.toString.call(new Date());  // '[object Date]'
Object.prototype.toString.call(null);        // '[object Null]'

Інші корисні перевірки:

  • Number.isNaN(x), Number.isInteger(x), Number.isFinite(x) - на відміну від глобальних isNaN/isFinite, не приводять аргумент до числа.
  • value === null - для null.
  • value !== null && typeof value === 'object' - «справжній» об'єкт.

Для даних ззовні (відповідь API, localStorage, форма) ручні перевірки швидко стають громіздкими. Тут використовують схеми валідації (Zod, Valibot): вони одночасно перевіряють дані під час виконання й дають TypeScript-тип.

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

Number точно представляє цілі числа лише до Number.MAX_SAFE_INTEGER = 2^53 - 1 = 9 007 199 254 740 991. Далі сусідні цілі числа вже неможливо розрізнити:

9007199254740993 === 9007199254740992; // true

BigInt - окремий тип цілих чисел довільної довжини:

const big = 9007199254740993n;      // суфікс n
big + 2n;                           // 9007199254740995n
BigInt('123456789012345678901234567890');

Обмеження BigInt: не можна змішувати з Number в арифметиці (1n + 1 - TypeError), Math.* з ним не працює, ділення відкидає дробову частину, а JSON.stringify кидає помилку.

Реальна проблема - ID з бекенду. Twitter/X ID, Snowflake-ідентифікатори, bigint-ключі з бази можуть перевищувати 2^53. JSON.parse перетворює число на Number і тихо спотворює останні цифри:

JSON.parse('{"id": 1234567890123456789}').id; // 1234567890123456800

Запит з таким ID потім шукає не той запис - і помилка проявляється далеко від причини.

Рішення:

  • Віддавати великі ID рядками - найпоширеніший підхід (Twitter API повертає і id, і id_str). У Laravel - каст до рядка в API Resource.
  • JSON.parse з reviver і context.source (нові браузери й Node.js) дає доступ до сирого тексту числа, щоб перетворити його на BigInt.
  • ID - це ідентифікатор, а не число: арифметика над ним не потрібна, тож рядок - природний тип.

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

Коли об'єкт опиняється там, де потрібен примітив (+obj, obj + '', `${obj}`, obj > 5, obj == 1), рушій викликає перетворення на примітив з «підказкою» (hint), якого типу очікують:

  • 'number' - арифметика й порівняння: +obj, obj * 2, obj > 5;
  • 'string' - шаблонні рядки, String(obj), ключі об'єкта;
  • 'default' - коли незрозуміло: бінарний +, ==.

Алгоритм:

  1. якщо є метод obj[Symbol.toPrimitive](hint) - викликається він;
  2. інакше для 'string' пробуються toString(), потім valueOf();
  3. для 'number' і 'default' - спершу valueOf(), потім toString();
  4. перший результат-примітив і використовується. Якщо обидва повернули об'єкт - TypeError.
const price = {
  amount: 42,
  valueOf() { return this.amount; },
  toString() { return `${this.amount} грн`; },
};

price + 1;       // 43        (default → valueOf)
price * 2;       // 84        (number → valueOf)
`${price}`;      // '42 грн'  (string → toString)
String(price);   // '42 грн'

Повний контроль - Symbol.toPrimitive:

const money = {
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return 42;
    if (hint === 'string') return '42 грн';
    return 'default';
  },
};

+money;          // 42
`${money}`;      // '42 грн'
money + '';      // 'default'

Як це пояснює «дивацтва» JavaScript:

  • [] + [] → '': масиви через toString() стають порожніми рядками;
  • [] + {} → '[object Object]';
  • [5] * 2 → 10: [5].toString() = '5', потім число;
  • Date - єдиний вбудований об'єкт, для якого 'default' означає рядок: date + 1 дає рядок з датою й одиницею, а date - 1 - число (мітку часу мінус 1).

Практична цінність:

  • порівняння дат a < b працює через valueOf(), що повертає мілісекунди;
  • власні числові типи (гроші, вектори) можуть поводитися природно в шаблонах, але арифметику через valueOf краще не будувати: price1 + price2 дасть число й «загубить» валюту. Явні методи (add, format) надійніші;
  • в об'єктах-значеннях, що потрапляють у шаблони, корисний змістовний toString() - замість [object Object] у логах і повідомленнях.

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

JSON.stringify вміє лише те, що є в JSON: об'єкти, масиви, рядки, скінченні числа, true/false, null. Усе інше перетворюється - і часто тихо.

JSON.stringify({
  a: undefined,        // властивість зникає
  b: () => 1,          // зникає
  c: Symbol('x'),      // зникає
  d: NaN,              // null
  e: Infinity,         // null
  f: new Date(0),      // '1970-01-01T00:00:00.000Z'
  g: [undefined, () => 1],  // [null, null] - у масиві не зникають, а стають null
});
// '{"d":null,"e":null,"f":"1970-01-01T00:00:00.000Z","g":[null,null]}'

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

  • undefined у об'єкті - властивість зникає. Для API, що розрізняє «поле не передано» і «поле очистити», треба явно надсилати null;
  • NaN і Infinity стають null - сервер отримає null замість помилки в розрахунку;
  • дати стають рядками ISO в UTC (через метод toJSON). JSON.parse назад їх не перетворює - на клієнті після розбору це рядки;
  • Map і Set серіалізуються як {} - дані втрачаються без попередження. Перетворюйте явно: Object.fromEntries(map), [...set];
  • BigInt кидає TypeError («Do not know how to serialize a BigInt»). Потрібно перетворювати на рядок вручну;
  • циклічні посилання (obj.self = obj, вузли DOM, моделі з двосторонніми зв'язками) кидають TypeError.

Керування серіалізацією:

// метод toJSON - об'єкт сам вирішує, як виглядати в JSON
class Money {
  constructor(amount, currency) { Object.assign(this, { amount, currency }); }
  toJSON() { return { amount: String(this.amount), currency: this.currency }; }
}

// replacer-функція: перетворити BigInt, приховати пароль
JSON.stringify(data, (key, value) => {
  if (typeof value === 'bigint') return value.toString();
  if (key === 'password') return undefined;
  return value;
});

// replacer-масив - білий список полів
JSON.stringify(user, ['id', 'name']);

// відступи для читабельності
JSON.stringify(data, null, 2);

Зворотний бік - JSON.parse з reviver:

JSON.parse(text, (key, value) =>
  typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value) ? new Date(value) : value,
);

Наслідок для «глибокого копіювання» через JSON.parse(JSON.stringify(x)): воно губить undefined, функції, перетворює дати на рядки, Map на {}, падає на циклах. Для копіювання є structuredClone(), що коректно обробляє дати, Map, Set і цикли.

Докладніше в документації: JSON.stringify()