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 з типу функції (корисно для обгорток над методами).
Абстрактний клас не можна створити напряму (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 } }
Які помилки це ловить:
- перейменування в батьківському класі - нащадок продовжує «перевизначати» метод, якого вже немає;
- випадкове перевизначення - у нащадку додали метод
save(), не помітивши, що такий уже є в базовому класі, і зламали його логіку; - друкарські помилки -
toArary()зoverrideодразу дасть помилку.
Аналогія з PHP: атрибут #[\Override] з PHP 8.3 робить те саме - перевіряє, що метод справді перевизначає батьківський.
Що варто знати:
overrideпрацює для методів, властивостей і аксесорів;- для реалізації абстрактних членів
overrideдозволений, але навіть зnoImplicitOverrideне обов'язковий - багато команд усе одно пишуть його для однаковості; - модифікатор зникає при компіляції - на виконання не впливає.
Рекомендація: вмикати noImplicitOverride у проєктах, де є успадкування класів. Це ще один прапорець, який дешево ловить реальні помилки і не входить у strict.
Клас, як і функція, може мати параметри типу - тоді один клас працює з різними типами даних зі збереженням перевірок.
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).