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

Питання на співбесіді: Функції й класи

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

14 питань

Тип функції складається з типів параметрів і типу результату:

function formatPrice(amount: number, currency: string): string {
  return `${amount.toFixed(2)} ${currency}`;
}

// тип функції окремо - для колбеків і змінних
type Formatter = (amount: number, currency: string) => string;
const uah: Formatter = (amount) => `${amount} грн`;   // параметрів може бути менше

Необов'язковий параметр - знак ?. Усередині функції він має тип T | undefined, тож його треба перевіряти:

function greet(name?: string) {
  return `Привіт, ${name ?? 'гостю'}`;
}

Параметр зі значенням за замовчуванням - тип виводиться зі значення, і всередині функції undefined вже не буде:

function paginate(page = 1, perPage = 20) { /* page: number */ }

Rest-параметр збирає решту аргументів у масив чи кортеж:

function sum(...numbers: number[]): number {
  return numbers.reduce((total, n) => total + n, 0);
}

function log(level: 'info' | 'error', ...messages: unknown[]) {}

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

  • необов'язкові параметри йдуть після обов'язкових;
  • name?: string і name: string | undefined - різне: другий обов'язково передати, хай навіть undefined;
  • тип результату можна не писати - TypeScript виведе його. Для публічних функцій (експорт з модуля, бібліотеки) явний тип корисний: помилка в реалізації не змінить мовчки контракт;
  • функція з колбеком меншої арності сумісна - [1, 2].forEach((n) => ...) не вимагає всіх трьох параметрів колбека.

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

function createUser({ name, role = 'user' }: { name: string; role?: 'user' | 'admin' }) {}

Докладніше в документації: Функції: необов'язкові параметри

Ці три типи описують різні ситуації «функція нічого корисного не повертає».

void - результат функції не використовується:

function logEvent(name: string): void {
  console.log(name);
}

Усередині функції з явно вказаним void повертати значення не можна - return 1 дасть помилку.

Але тип функції з void поводиться інакше: функцію, що щось повертає, можна присвоїти змінній типу () => void:

type Callback = () => void;
const cb: Callback = () => 42;   // дозволено

const items: number[] = [];
[1, 2].forEach((n) => items.push(n));   // push повертає number - і це нормально

Це зроблено навмисно: колбек на кшталт forEach ігнорує результат, і вимагати «нічого не повертати» було б незручно. Наслідок - const result = cb() має тип void, а не number: використовувати його не варто.

undefined - функція повертає значення undefined, і викликач може на це покладатися:

function find(id: number): User | undefined {}

Для «нічого не повертає» в типах функцій зазвичай пишуть void, а undefined - коли значення має сенс (T | undefined).

never - функція ніколи не завершується нормально: кидає виняток або нескінченно виконується.

function fail(message: string): never {
  throw new Error(message);
}

function assertNever(value: never): never {
  throw new Error(`Неочікуване значення: ${value}`);
}

TypeScript використовує never в аналізі потоку: код після виклику fail() вважається недосяжним, а в звуженні типу never означає «варіантів не лишилося» - це основа перевірки вичерпності в switch.

Коротко: void - «результат не цікавить», undefined - «повертає саме undefined», never - «не повертає взагалі».

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

TypeScript має модифікатори доступу public, protected, private:

  • public (за замовчуванням) - доступно всім;
  • protected - класу й нащадкам;
  • private - лише самому класу.
class Account {
  private balance = 0;
  protected currency = 'UAH';
  #pin = '1234';   // приватне поле JavaScript
}

Ключова різниця - коли діє обмеження.

private TypeScript - лише перевірка компілятора. Після компіляції це звичайна властивість об'єкта: вона видна в Object.keys, у JSON.stringify, доступна з JavaScript-коду. Навіть у TypeScript є лазівка - доступ через квадратні дужки:

const account = new Account();
account.balance;      // помилка компіляції
account['balance'];   // дозволено - навмисна «аварійна дверцята»

#private - справжня приватність під час виконання. Поле недосяжне ззовні класу в принципі: ні через дужки, ні через Object.keys, ні через рефлексію. Спроба звернутися до #pin поза класом - синтаксична помилка JavaScript.

Коли що обирати:

  • #private - коли потрібна гарантія: бібліотеки, код, який використовують з JavaScript, дані, що не повинні потрапити в серіалізацію;
  • private - якщо достатньо перевірки на етапі компіляції й потрібна гнучкість: тести, що зазирають у стан, сумісність зі старим кодом, бібліотеки, яким потрібен доступ до полів (ORM, серіалізатори).

Нюанси #private:

  • #field in obj - перевірка, що об'єкт створено цим класом;
  • приватні поля не працюють через Proxy (Vue reactive, MobX): метод, викликаний на проксі, падає з TypeError;
  • protected аналога в JavaScript не має - лише перевірка TypeScript.

readonly - окремий модифікатор: поле можна присвоїти лише при оголошенні чи в конструкторі. Теж діє тільки під час компіляції.

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

Parameter properties - скорочення TypeScript: модифікатор доступу в параметрі конструктора одночасно оголошує поле класу й присвоює йому значення.

class User {
  constructor(
    public readonly id: number,
    private name: string,
  ) {}
}

// те саме, що
class User {
  public readonly id: number;
  private name: string;

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }
}

Зручно й коротко - тому цей запис став популярним (Angular, NestJS, багато бекенд-коду на TypeScript).

Проблема: це не «лише типи». Більшість синтаксису TypeScript можна просто стерти - прибрати анотації, і лишиться коректний JavaScript. Parameter properties так не працюють: щоб отримати JavaScript, компілятор має згенерувати код (присвоєння this.id = id).

Те саме з enum (генерує об'єкт), namespace з виконуваним кодом і import x = require().

Чому це стало важливим:

  • Node.js 22.18+/23.6+ виконує .ts файли напряму через «стирання типів» (type stripping) - без компіляції. Синтаксис, що потребує генерації коду, там не підтримується;
  • інструменти на кшталт швидких транспіляторів теж простіше працюють зі «стираним» синтаксисом.

Прапорець erasableSyntaxOnly (TypeScript 5.8+) забороняє такий синтаксис у проєкті - помилка компілятора «This syntax is not allowed when erasableSyntaxOnly is enabled»:

{ "compilerOptions": { "erasableSyntaxOnly": true } }

Чим замінювати:

  • parameter properties - звичайні поля й присвоєння в конструкторі;
  • enum - об'єкт з as const і тип-об'єднання:
const Status = { Draft: 'draft', Published: 'published' } as const;
type Status = (typeof Status)[keyof typeof Status];

Практичний висновок: у новому коді, особливо для Node.js без збирання, варто вмикати erasableSyntaxOnly - тоді TypeScript-код лишається «JavaScript з анотаціями». Parameter properties не помилка, але прив'язують код до компіляції.

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

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

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

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-класи

У TypeScript дві несумісні реалізації декораторів.

Стандартні декоратори (TypeScript 5.0+, відповідають пропозиції TC39) - працюють без прапорців:

function logged<This, Args extends unknown[], R>(
  target: (this: This, ...args: Args) => R,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => R>,
) {
  return function (this: This, ...args: Args): R {
    console.log(`виклик ${String(context.name)}`);
    return target.call(this, ...args);
  };
}

class OrderService {
  @logged
  place(id: number) { /* ... */ }
}

Декоратор отримує значення (метод, поле, клас) і об'єкт context: kind, name, static, private, addInitializer(), access, metadata. Повертає заміну чи нічого.

Ключове слово accessor - поле з автоматичними гетером і сетером, які декоратор може перехопити (основа реактивних полів у Lit, MobX):

class Counter {
  @observable accessor count = 0;
}

Експериментальні декоратори (experimentalDecorators: true) - стара реалізація ранньої пропозиції. Сигнатура інша: (target, propertyKey, descriptor). На них побудовані Angular, NestJS, TypeORM, class-validator, Inversify.

function old(target: any, key: string, descriptor: PropertyDescriptor) {}

Без прапорця такий декоратор не скомпілюється: TypeScript перевіряє його як стандартний і повідомляє, що під час виконання він отримає 2 аргументи, а очікує 3.

Відмінності, що мають значення:

  • декоратори параметрів (constructor(@Inject() service: X)) є лише в експериментальних - стандарт їх не має. Саме на них тримається впровадження залежностей у NestJS і Angular;
  • emitDecoratorMetadata (метадані типів для DI, reflect-metadata) - лише з експериментальними;
  • метадані в стандарті - context.metadata і Symbol.metadata, без автоматичних типів параметрів;
  • порядок виконання й семантика ініціалізації полів відрізняються.

Що обирати:

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

Стан стандарту: пропозиція на стадії 3; рушії браузерів і Node.js нативно декоратори ще не виконують - TypeScript компілює їх у допоміжний код (__esDecorate). Тому декоратори несумісні з erasableSyntaxOnly-підходом «просто стерти типи».

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

Поля класу в TypeScript з'явилися раніше, ніж стандартні поля класів у JavaScript, і TypeScript довго компілював їх як присвоєння в конструкторі. Стандарт же визначає поля через Object.defineProperty - з іншою семантикою.

useDefineForClassFields перемикає на стандартну семантику. Він увімкнений за замовчуванням, коли target - ES2022 чи новіший (а з TypeScript 6 типова ціль - поточна версія ECMAScript, тож для нових проєктів це майже завжди так).

Де різниця стає помітною:

1. Поле без ініціалізатора в нащадку затирає значення з батька:

class Base {
  name: string;
  constructor() {
    this.name = 'base';
    this.init();
  }
  init() {}
}

class Child extends Base {
  name!: string;   // лише уточнення типу
}

new Child().name;   // зі стандартною семантикою - undefined!

Оголошене в нащадку поле визначається заново (зі значенням undefined) після батьківського конструктора. Рішення - declare name: string;: це оголошення лише для типів, без поля під час виконання.

Компілятор попереджає про обидва випадки нижче: «Property 'name' will overwrite the base property... add a 'declare' modifier» (TS2612) і «'value' is defined as an accessor in class..., but is overridden here as an instance property» (TS2610). Ці помилки не варто глушити - вони описують реальну зміну поведінки.

2. Поля перекривають сетери батька:

class Base {
  set value(v: number) { console.log('setter', v); }
}
class Child extends Base {
  value = 1;   // стандарт: власна властивість, сетер не викликається
}

3. Порядок ініціалізації з parameter properties: поля ініціалізуються до тіла конструктора, і поле, що використовує parameter property, може побачити undefined.

4. Бібліотеки з декораторами чи реактивністю (старі MobX, деякі ORM), що покладалися на присвоєння й сетери прототипу, ламаються зі стандартною семантикою.

Що робити:

  • у новому коді - лишати стандартну семантику (так поводиться і браузер, і Node.js, і будь-який інший транспілятор);
  • для перевизначення типу поля в нащадку - declare, а не повторне оголошення;
  • при міграції старого коду - перевірити класи з успадкуванням полів і сетерів; тимчасово можна вимкнути прапорець, але це розходження з реальним JavaScript.

Чому це не «дрібниця налаштувань»: код, що працює однаково в тестах (одна ціль компіляції) і по-різному в продакшені (інша), - одна з найнеочевидніших помилок при зміні target.

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

Сучасні Node.js (з 22.18 і 23.6) запускають .ts файли напряму: перед виконанням вони просто стирають анотації типів, не перевіряючи їх і не компілюючи. Так само працюють швидкі інструменти на кшталт Bun, Deno та трансформацій у збирачах.

Що можна стерти - більша частина TypeScript: анотації, інтерфейси, type, generics, as, satisfies, import type. Після видалення лишається коректний JavaScript.

Що стерти неможливо - синтаксис, якому потрібна генерація коду:

  • enum - створює об'єкт під час виконання;
  • namespace з кодом усередині;
  • parameter properties (constructor(private name: string)) - генерують присвоєння;
  • import x = require('...'), export =;
  • старі декоратори з emitDecoratorMetadata.

Прапорець erasableSyntaxOnly (TypeScript 5.8+) робить такий синтаксис помилкою компіляції - код гарантовано запуститься через стирання типів:

{
  "compilerOptions": {
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "noEmit": true
  }
}

verbatimModuleSyntax доповнює його: імпорти лише типів мають бути явно позначені import type, щоб стирання не лишило імпорт неіснуючого значення.

Замінники:

// замість enum
const Role = { Admin: 'admin', Editor: 'editor' } as const;
type Role = (typeof Role)[keyof typeof Role];

// замість parameter properties
class User {
  readonly id: number;
  constructor(id: number) { this.id = id; }
}

Як це змінює роль tsc: компілятор дедалі частіше виконує лише перевірку типів (noEmit), а виконання й збирання роблять інші інструменти. TypeScript 7 (нативний компілятор на Go) робить таку перевірку в рази швидшою - і поділ «типи перевіряє tsc, код запускає рушій» стає типовою схемою.

Обмеження стирання типів у Node.js:

  • типи не перевіряються під час запуску - помилки типів знайде лише tsc;
  • tsconfig ігнорується - paths, baseUrl (у TypeScript 7 його вже немає) не працюють;
  • імпорти з розширенням .ts - потрібно allowImportingTsExtensions у tsconfig або перезапис розширень (rewriteRelativeImportExtensions) при компіляції бібліотеки.

Висновок: для коду, який може запускатися без збирання (скрипти, сервіси на Node.js), «стираний» TypeScript - розумний стандарт. Для фронтенду через збирач обмеження м'якші, але й там enum і parameter properties дедалі частіше замінюють.

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

Аксесори (гетер і сетер) у класах і об'єктах виглядають ззовні як звичайна властивість, але виконують код при читанні й записі.

class Temperature {
  #celsius = 0;

  get fahrenheit(): number {
    return this.#celsius * 1.8 + 32;
  }

  set fahrenheit(value: number) {
    this.#celsius = (value - 32) / 1.8;
  }
}

Правила TypeScript:

  • властивість лише з гетером автоматично readonly - запис дає помилку;
  • з TypeScript 5.1 гетер і сетер можуть мати непов'язані типи (раніше тип гетера мусив бути сумісним з типом сетера).

Навіщо різні типи - сетер приймає ширше, гетер повертає нормалізоване:

class Field {
  #value = '';

  get value(): string {
    return this.#value;
  }

  set value(input: string | number | null) {
    this.#value = input === null ? '' : String(input);
  }
}

const field = new Field();
field.value = 42;          // дозволено
field.value.toUpperCase(); // завжди string

Так влаштовано чимало API браузера: властивість приймає ширший набір значень (рядок, null), а повертає нормалізований рядок. Опис таких API в .d.ts став точнішим саме завдяки цій можливості.

Те саме в інтерфейсах і типах об'єктів:

interface Thing {
  get size(): number;
  set size(value: number | string | boolean);
}

Ключове слово accessor (стандартні декоратори) - коротший запис поля з автоматичними гетером і сетером над прихованим сховищем: accessor count = 0. Корисне разом із декораторами, що перехоплюють доступ.

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

  • дорогі гетери виглядають як дешеві поля - виклик у циклі може приховано коштувати багато;
  • побічні ефекти в гетері - погана практика: читання не повинно змінювати стан;
  • серіалізація: JSON.stringify не бачить гетерів класу (вони в прототипі) - потрібен toJSON();
  • поля проти аксесорів у нащадках: з useDefineForClassFields поле в нащадку перекриває сетер батька - сетер не викликається.

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