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

TypeScript: питання на співбесіді рівня Middle

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

35 питань

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

Умовний тип вибирає один з двох типів залежно від перевірки - тернарний оператор на рівні типів:

T extends U ? X : Y

«Якщо T можна присвоїти U - тип X, інакше Y».

type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>;   // true
type B = IsString<42>;        // false

Практичні приклади:

1. Тип результату залежно від аргументу:

type ApiResult<T extends 'one' | 'many'> = T extends 'one' ? User : User[];

declare function fetchUsers<T extends 'one' | 'many'>(mode: T): Promise<ApiResult<T>>;

const one = await fetchUsers('one');     // User
const many = await fetchUsers('many');   // User[]

2. Фільтрація об'єднань - так влаштовані вбудовані Exclude і Extract:

type Exclude<T, U> = T extends U ? never : T;

type Events = 'click' | 'focus' | 'keydown' | 'keyup';
type KeyEvents = Extract<Events, `key${string}`>;   // 'keydown' | 'keyup'
type Other = Exclude<Events, `key${string}`>;       // 'click' | 'focus'

never у гілці «викидає» член об'єднання.

3. NonNullable<T>, ReturnType<T>, Awaited<T> - теж умовні типи (останні два - з infer).

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

type ToArray<T> = T extends unknown ? T[] : never;
type R = ToArray<string | number>;   // string[] | number[], а не (string | number)[]

Обмеження й поради:

  • читабельність: вкладені умовні типи на кілька рівнів важко читати й підтримувати. Розбивайте на іменовані проміжні типи;
  • у реалізації функції TypeScript не може звузити умовний тип повернення за аргументом - доводиться використовувати перевантаження функції або явне приведення в реалізації;
  • глибина рекурсії обмежена: для рекурсивних умовних типів компілятор зупиняється з помилкою «Type instantiation is excessively deep».

Коли умовні типи доречні: бібліотеки й утиліти, типи API-клієнтів, похідні типи. У прикладному коді частіше вистачає об'єднань, перевантажень і узагальнень без умов.

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

infer оголошує змінну типу всередині умови умовного типу: «якщо T має таку форму, дістань звідти ось цю частину».

type ElementOf<T> = T extends readonly (infer U)[] ? U : never;

type A = ElementOf<string[]>;              // string
type B = ElementOf<readonly [1, 'a']>;     // 1 | 'a'
type C = ElementOf<number>;                // never - не масив

Так влаштовані вбудовані утиліти:

type ReturnType<T> = T extends (...args: any) => infer R ? R : any;
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
type Awaited<T> = /* рекурсивно розгортає Promise і thenable */;

Практичні приклади:

// тип даних з функції, що повертає проміс
type Data<T> = T extends (...args: any[]) => Promise<infer D> ? D : never;
type UserData = Data<typeof fetchUser>;

// перший аргумент функції
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;

// тип props React-компонента
type PropsOf<C> = C extends (props: infer P) => any ? P : never;

infer з шаблонними рядками - розбір рядкових типів:

type RouteParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | RouteParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? Param
      : never;

type P = RouteParams<'/teams/:teamId/users/:userId'>;   // 'teamId' | 'userId'

Так роутери (React Router, TanStack Router) типізують параметри маршрутів з рядка шляху.

infer з обмеженням (TS 4.7+) - дістати лише якщо підходить під тип:

type FirstString<T> = T extends [infer S extends string, ...unknown[]] ? S : never;

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

  • infer працює лише в частині extends умовного типу;
  • якщо структура не збіглася, спрацьовує гілка «інакше» - зазвичай never, і про це легко забути: тип мовчки стає never, а не помилкою;
  • кілька кандидатів для однієї змінної (infer U у кількох позиціях) дають об'єднання чи перетин залежно від позиції (коваріантна чи контраваріантна).

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

Докладніше в документації: Виведення в умовних типах

Коли умовний тип перевіряє «голий» параметр типу (T extends ..., а не T[] чи [T]), а в T передано об'єднання, умова застосовується до кожного члена окремо, і результати об'єднуються:

type ToArray<T> = T extends unknown ? T[] : never;

type A = ToArray<string | number>;
// = ToArray<string> | ToArray<number>
// = string[] | number[]

Навіщо так зроблено: на цьому побудовані фільтри об'єднань.

type Exclude<T, U> = T extends U ? never : T;
type R = Exclude<'a' | 'b' | 'c', 'a'>;   // 'b' | 'c'

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

Коли розподіл заважає - потрібно перевірити об'єднання цілком:

type ToArrayAll<T> = [T] extends [unknown] ? T[] : never;

type B = ToArrayAll<string | number>;   // (string | number)[]

Загортання в кортеж ([T]) робить параметр «не голим» - розподілу немає.

Найвідоміша пастка - never:

type IsNever<T> = T extends never ? true : false;
type X = IsNever<never>;   // never, а не true!

never - порожнє об'єднання. Розподіл по порожньому об'єднанню дає порожній результат, тобто never. Правильна перевірка:

type IsNever<T> = [T] extends [never] ? true : false;
type Y = IsNever<never>;   // true

Ще приклади, де важливо розуміти розподіл:

  • boolean - це true | false, тож T extends true ? 'yes' : 'no' для boolean дасть 'yes' | 'no';
  • перевірка, чи тип є об'єднанням - на основі порівняння розподіленого й нерозподіленого результатів;
  • keyof об'єднання - лише спільні ключі, а розподільний T extends unknown ? keyof T : never - усі ключі всіх членів.

Правило: розподіл є лише тоді, коли параметр типу стоїть зліва від extends сам по собі і в нього підставлено об'єднання. T[] extends ..., [T] extends ..., Promise<T> extends ... - не розподіляються.

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

Mapped type проходить по ключах і будує новий тип: { [K in keyof T]: ... }. Крім типу значень, можна змінювати модифікатори й самі ключі.

Модифікатори readonly і ? - додати (+, можна опускати) чи прибрати (-):

type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };   // усі поля обов'язкові

type Locked = { readonly id: number; name?: string };
type M = Mutable<Locked>;    // { id: number; name?: string }
type C = Concrete<Locked>;   // { readonly id: number; name: string }

Вбудовані Partial, Required, Readonly - саме такі типи.

Перейменування ключів через as (TS 4.1+):

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }

string & K - бо ключі можуть бути number | symbol, а Capitalize працює лише з рядками.

Фільтрація ключів - якщо as дає never, ключ зникає:

type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type S = OnlyStrings<{ id: number; name: string; email: string }>;
// { name: string; email: string }

type WithoutMeta<T> = { [K in keyof T as Exclude<K, `_${string}`>]: T[K] };

Практичні застосування:

  • обробники подій з полів: { [K in keyof State as \on${Capitalize<...>}Change`]: (value: State[K]) => void }`;
  • типи форм: з моделі даних - тип помилок { [K in keyof T]?: string[] } (як помилки валідації Laravel);
  • вибір полів за типом значень: лише числові поля для сортування, лише булеві для фільтрів.

Важлива деталь: mapped type вигляду { [K in keyof T]: ... } (гомоморфний) зберігає модифікатори вихідного типу - readonly і ? переходять автоматично. Якщо перебирати не keyof T, а довільне об'єднання ([K in 'a' | 'b']), модифікатори не копіюються.

Масиви й кортежі у гомоморфних mapped types лишаються масивами й кортежами - тип застосовується до елементів, а не до методів масиву.

Докладніше в документації: Mapped types: перейменування ключів через as

Крім утиліт для об'єктів (Partial, Pick, Omit, Record), TypeScript має утиліти для функцій, промісів і керування виведенням типів.

Функції й класи:

async function fetchUser(id: number, withPosts = false) {
  return { id, name: 'Оля', posts: withPosts ? [] : undefined };
}

type Args = Parameters<typeof fetchUser>;          // [id: number, withPosts?: boolean]
type Result = ReturnType<typeof fetchUser>;        // Promise<{ ... }>
type UserData = Awaited<ReturnType<typeof fetchUser>>;   // { id: number; name: string; ... }

class Service { constructor(public url: string) {} }
type CtorArgs = ConstructorParameters<typeof Service>;   // [url: string]
type Instance = InstanceType<typeof Service>;            // Service

Awaited<T> рекурсивно розгортає проміси: Awaited<Promise<Promise<number>>> - number. Так само типізовано await і Promise.all.

Об'єднання:

  • Exclude<T, U> - прибрати з об'єднання;
  • Extract<T, U> - залишити лише відповідні;
  • NonNullable<T> - прибрати null і undefined.

NoInfer<T> (TS 5.4) - заборонити виводити параметр типу з цієї позиції:

function createSelect<T extends string>(options: T[], defaultValue: NoInfer<T>) {}

createSelect(['sm', 'md', 'lg'], 'md');   // ок
createSelect(['sm', 'md', 'lg'], 'xl');   // помилка

Без NoInfer TypeScript вивів би T з обох аргументів - як 'sm' | 'md' | 'lg' | 'xl' - і помилки не було б. NoInfer каже: тип визначають лише варіанти, а значення за замовчуванням має їм відповідати.

Рядки: Uppercase, Lowercase, Capitalize, Uncapitalize - для шаблонних рядкових типів.

ThisParameterType, OmitThisParameter - для функцій з типізованим this.

Навіщо знати утиліти:

  • похідні типи замість дублювання: тип відповіді API береться з функції запиту, тип аргументів - з функції, що їх приймає;
  • типи з бібліотек без експорту: якщо бібліотека не експортує тип опцій, Parameters<typeof libFn>[0] дістане його;
  • читабельність: стандартні назви зрозумілі всім, на відміну від власних умовних типів.

Пастка ReturnType з перевантаженими функціями: береться тип останньої сигнатури перевантаження, а не всіх. Для таких функцій похідні типи краще описати явно.

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

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

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

Питання рівня Middle з реальних технічних співбесід - 35 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.

Інші рівні
Junior 36 Senior 29

Готуєтесь до співбесіди не просто так: зараз на сайті 78 відкритих вакансій рівня Middle. Переглянути вакансії