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

Middle: питання на співбесіді з теми «Функції й класи»

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

5 питань

Перевантаження описують кілька способів викликати функцію з різними типами результату залежно від аргументів. Складаються з кількох сигнатур і однієї реалізації:

function parse(value: string): number;
function parse(value: number): string;
function parse(value: string | number): string | number {
  return typeof value === 'string' ? Number(value) : String(value);
}

parse('42');   // number
parse(42);     // string

Правила:

  • реалізація ззовні не видна - викликати функцію можна лише за сигнатурами перевантажень. Сигнатура реалізації має бути сумісною з усіма ними;
  • TypeScript перебирає перевантаження по черзі й бере перше відповідне - тому конкретніші ставлять вище за загальніші;
  • реалізація сама перевіряє, з яким варіантом її викликали (typeof, in), - компілятор не перевіряє, що кожна гілка повертає «правильний» тип для своєї сигнатури.

Головна пастка - аргумент-об'єднання:

declare const input: string | number;
parse(input);   // помилка: жодне перевантаження не приймає string | number

Кожне перевантаження приймає лише свій тип, а об'єднання не підходить жодному. Доводиться додавати ще одну сигнатуру (value: string | number): string | number.

Коли краще без перевантажень:

  • результат не залежить від типу аргументу - звичайний тип-об'єднання;
  • результат залежить від аргументу передбачувано - generic чи умовний тип:
function first<T>(items: T[]): T | undefined {
  return items[0];
}
  • різні способи виклику з різною кількістю параметрів - часто краще об'єкт параметрів чи кілька окремих функцій з виразними назвами (parseNumber, formatNumber).

Коли перевантаження доречні:

  • типи для API, яке вже так влаштоване - бібліотеки, .d.ts для JavaScript-коду (document.createElement('canvas') повертає HTMLCanvasElement - перевантаження в типах DOM);
  • залежність результату від літерального значення аргументу, яку незручно виразити умовним типом.

Перевантаження методів класу пишуться так само, а в типах об'єктів - кількома сигнатурами виклику.

Докладніше в документації: Перевантаження функцій

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

Параметр this - перший «фіктивний» параметр, який не існує під час виконання:

function disable(this: HTMLButtonElement, event: MouseEvent) {
  this.disabled = true;
}

button.addEventListener('click', disable);   // ок: this - кнопка

const handler = disable;
handler(new MouseEvent('click'));   // помилка: this має тип void, а не HTMLButtonElement

У скомпільованому JavaScript параметр this зникає.

Методи класів - TypeScript знає, що this - екземпляр класу. Але при «відриві» методу від об'єкта this губиться під час виконання:

class Counter {
  count = 0;
  increment() { this.count++; }
}

const c = new Counter();
const inc = c.increment;
inc();   // TypeError під час виконання: this - undefined

Рішення:

  • стрілкова функція як поле - this захоплюється при створенні: increment = () => { this.count++; }; (ціна - окрема функція на кожен екземпляр, і її немає в прототипі);
  • bind у конструкторі;
  • параметр this: Counter у методі - тоді TypeScript сам покаже помилку при виклику без об'єкта.

this як тип результату - для ланцюжків викликів, що коректно працюють у нащадках:

class QueryBuilder {
  where(column: string, value: unknown): this {
    // ...
    return this;
  }
}

class PostQuery extends QueryBuilder {
  published(): this { return this.where('status', 'published'); }
}

new PostQuery().where('id', 1).published();   // where повернув PostQuery, а не QueryBuilder

Тип-перевірка this is T - type guard для методів:

class Shape {
  isCircle(): this is Circle { return this instanceof Circle; }
}

ThisParameterType<F> і OmitThisParameter<F> - утиліти, щоб отримати або прибрати тип this з типу функції (корисно для обгорток над методами).

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

Абстрактний клас не можна створити напряму (new), лише успадкувати. Він може містити і реалізацію, і абстрактні члени, які зобов'язаний реалізувати нащадок.

abstract class Notifier {
  abstract send(to: string, message: string): Promise<void>;

  async notifyAll(recipients: string[], message: string) {
    for (const to of recipients) {
      await this.send(to, message);
    }
  }
}

class EmailNotifier extends Notifier {
  async send(to: string, message: string) {
    // реалізація відправки
  }
}

new Notifier();        // помилка: абстрактний клас
new EmailNotifier();   // ок

Нащадок, що не реалізував усі абстрактні члени, теж мусить бути abstract.

Чим відрізняється від інтерфейсу:

Абстрактний клас Інтерфейс
реалізація методів може мати ні
поля зі значеннями, конструктор так ні
модифікатори protected, private так ні
існує під час виконання так (це клас JavaScript) ні (зникає при компіляції)
кількість на клас лише один (extends) скільки завгодно (implements)
instanceof працює неможливо

Коли абстрактний клас:

  • є спільна реалізація, а нащадки відрізняються окремими кроками - шаблонний метод: загальний алгоритм у базовому класі, змінні кроки - абстрактні;
  • потрібен спільний стан чи конструктор;
  • потрібна перевірка instanceof.

Коли інтерфейс:

  • потрібен лише контракт - форма об'єкта чи класу;
  • клас має відповідати кільком контрактам;
  • об'єкти можуть бути не екземплярами класів (звичайні об'єкти, моки в тестах).

Абстрактні конструктори в типах - щоб прийняти «будь-який підклас», а не сам абстрактний клас:

function register(Ctor: abstract new () => Notifier) {}

Обережно з глибокими ієрархіями: абстрактний клас, від якого успадковують усе, з часом обростає методами «для деяких нащадків». Для повторного використання поведінки часто краща композиція - передати залежність через конструктор.

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

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

Модифікатор override робить намір явним:

class Model {
  toArray(): Record<string, unknown> { return {}; }
}

class User extends Model {
  override toArray() {
    return { ...super.toArray(), name: this.name };
  }
}

Що перевіряє компілятор:

  • метод з override мусить існувати в батьківському класі. Якщо батьківський toArray перейменували на toObject, у нащадку з'являється помилка - замість тихо «осиротілого» методу, який більше ніхто не викликає;
  • з прапорцем noImplicitOverride - навпаки: перевизначення без override теж стає помилкою («This member must have an 'override' modifier because it overrides a member in the base class»).
{ "compilerOptions": { "noImplicitOverride": true } }

Які помилки це ловить:

  1. перейменування в батьківському класі - нащадок продовжує «перевизначати» метод, якого вже немає;
  2. випадкове перевизначення - у нащадку додали метод save(), не помітивши, що такий уже є в базовому класі, і зламали його логіку;
  3. друкарські помилки - toArary() з override одразу дасть помилку.

Аналогія з PHP: атрибут #[\Override] з PHP 8.3 робить те саме - перевіряє, що метод справді перевизначає батьківський.

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

  • override працює для методів, властивостей і аксесорів;
  • для реалізації абстрактних членів override дозволений, але навіть з noImplicitOverride не обов'язковий - багато команд усе одно пишуть його для однаковості;
  • модифікатор зникає при компіляції - на виконання не впливає.

Рекомендація: вмикати noImplicitOverride у проєктах, де є успадкування класів. Це ще один прапорець, який дешево ловить реальні помилки і не входить у strict.

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

Клас, як і функція, може мати параметри типу - тоді один клас працює з різними типами даних зі збереженням перевірок.

class Repository<T extends { id: number }> {
  #items = new Map<number, T>();

  save(item: T): void {
    this.#items.set(item.id, item);
  }

  find(id: number): T | undefined {
    return this.#items.get(id);
  }

  all(): T[] {
    return [...this.#items.values()];
  }
}

const users = new Repository<User>();
users.save({ id: 1, name: 'Оля' });
users.find(1)?.name;   // тип User

Обмеження extends гарантує, що в T є id - інакше item.id всередині класу був би помилкою.

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

class Box<T> {
  constructor(public value: T) {}
}

const box = new Box(42);   // Box<number> - тип виведено

Чому статичні члени не можуть використовувати T:

class Repository<T> {
  static defaultItem: T;   // помилка: static members cannot reference class type parameters
}

Параметр типу належить екземпляру: new Repository<User>() і new Repository<Post>() - різні «версії» з різними T. А статичний член один на весь клас - для всіх екземплярів одразу. Яким має бути T у Repository.defaultItem? Відповіді немає.

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

class Repository<T> {
  static of<U extends { id: number }>(items: U[]): Repository<U> {
    const repo = new Repository<U>();
    items.forEach((item) => repo.save(item));
    return repo;
  }
}

Корисні прийоми:

  • значення за замовчуванням для параметра: class Cache<V = string>;
  • кілька параметрів: class Store<State, Action extends { type: string }>;
  • this як тип у методах - для ланцюжків, що зберігають тип нащадка.

Обмеження: параметри типу зникають під час виконання. Усередині класу не можна написати new T() чи x instanceof T - якщо потрібен конструктор, його передають явно: constructor(private make: new () => T).

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