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. Параметр типу має сенс, коли він пов'язує кілька місць: аргументи між собою або аргумент з результатом.
Звуження (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 це помічає.
Умовний тип вибирає один з двох типів залежно від перевірки - тернарний оператор на рівні типів:
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 з перевантаженими функціями: береться тип останньої сигнатури перевантаження, а не всіх. Для таких функцій похідні типи краще описати явно.
Перевантаження описують кілька способів викликати функцію з різними типами результату залежно від аргументів. Складаються з кількох сигнатур і однієї реалізації:
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).
Питання рівня Middle з реальних технічних співбесід - 35 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 78 відкритих вакансій рівня Middle. Переглянути вакансії