Питання на співбесіді з TypeScript
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
Крім утиліт для об'єктів (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).
Принцип прапорця: імпорти й експорти лишаються в JavaScript рівно такими, як написані, крім тих, що явно позначені type. Компілятор більше не вирішує сам, які імпорти прибрати.
{ "compilerOptions": { "verbatimModuleSyntax": true } }
Що змінюється:
import { User } from './models.js'; // помилка: User - лише тип, використайте import type
import type { User } from './models.js'; // зникне повністю
import { type User, save } from './api.js'; // лишиться import { save } from './api.js'
import { type User } from './api.js'; // лишиться import {} from './api.js' (модуль завантажиться)
import './polyfills.js'; // лишиться як є
Помилка: «'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled».
Навіщо:
1. Однаковий результат для всіх інструментів. Babel, esbuild, SWC, Vite, стирання типів у Node.js обробляють кожен файл окремо й не знають, чи є імпортована назва типом. Без явних type вони можуть залишити імпорт інтерфейсу (помилка під час виконання) або видалити імпорт, потрібний заради побічних ефектів. З verbatimModuleSyntax результат передбачуваний: що бачите, те й отримаєте.
2. Заміна старих прапорців. Він замінив importsNotUsedAsValues і preserveValueImports, а також бере на себе більшу частину задач isolatedModules.
3. Чіткі межі ESM і CommonJS. У файлах, що компілюються в CommonJS, прапорець забороняє ESM-синтаксис, який не можна перетворити буквально, - потрібно писати import x = require()/export =. Тому для CommonJS-проєктів він незручний; його природне середовище - ES-модулі й збирачі.
Пов'язані прапорці для сучасного проєкту:
isolatedModules- забороняє конструкції, які не можна скомпілювати пофайлово (наприклад, реекспорт типу безexport type);erasableSyntaxOnly- забороняє синтаксис, що потребує генерації коду (enum, parameter properties);- разом із
verbatimModuleSyntaxвони гарантують, що код можна просто «стерти» до JavaScript.
Міграція: правило @typescript-eslint/consistent-type-imports з автовиправленням переписує імпорти за кілька секунд.
Злиття оголошень - кілька оголошень з однаковою назвою в одній області видимості TypeScript об'єднує в одне.
Інтерфейси зливаються:
interface Box {
width: number;
}
interface Box {
height: number;
}
const box: Box = { width: 10, height: 20 }; // обидва поля обов'язкові
Правила:
- поля, що не збігаються, додаються;
- поле з тією самою назвою має бути того самого типу - інакше помилка;
- методи з однаковою назвою стають перевантаженнями, причому пізніші оголошення мають пріоритет.
type не зливається: повторне type Box = ... - помилка «Duplicate identifier». Це головна практична відмінність interface від type.
Namespace зливається з класом, функцією чи enum - так додають «статичні» члени:
function formatPrice(amount: number): string {
return `${amount} грн`;
}
namespace formatPrice {
export const currency = 'UAH';
}
formatPrice(10);
formatPrice.currency;
Так описують у .d.ts бібліотеки, де функція має ще й властивості (як jQuery $ і $.ajax).
Де злиття використовують на практиці:
- розширення чужих типів - доповнення модуля (module augmentation) і глобальних інтерфейсів (
Window,ProcessEnv) спирається саме на злиття інтерфейсів; - декларації бібліотек, які поєднують функцію й об'єкт;
- розширювані конфігурації - бібліотека оголошує порожній інтерфейс, а користувач доповнює його своїми полями, і бібліотека бачить їх типи (так зроблено реєстри маршрутів, подій, тем у багатьох бібліотеках).
Пастка: злиття працює й ненавмисно. Інтерфейс з назвою, яка вже є глобально (наприклад, ваш interface Event у файлі-скрипті), зіллється з вбудованим Event DOM. Тому власні типи варто тримати в модулях (файлах з import/export), де вони не потрапляють у глобальну область.
Що не зливається: класи з класами, type з будь-чим, змінні.
Доповнення модуля (module augmentation) додає поля до інтерфейсів, оголошених у чужому пакеті, не змінюючи сам пакет. Працює через злиття інтерфейсів.
Приклад з Vue - глобальна властивість у шаблонах:
// src/types/vue.d.ts
import type { Translator } from '../i18n';
declare module 'vue' {
interface ComponentCustomProperties {
$t: Translator;
}
}
Тепер $t має тип у всіх шаблонах і this в Options API.
Приклад з Pinia - власна опція стора, з Vue Router - типізоване meta:
import 'vue-router';
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean;
title?: string;
}
}
Express - поле в запиті. Типи Express оголошені в глобальному просторі імен, тому доповнюють його через declare global:
import type { User } from './models.js';
declare global {
namespace Express {
interface Request {
user?: User;
}
}
}
Обов'язкові умови:
- файл має бути модулем - містити хоча б один
importчиexportна верхньому рівні (часто додаютьexport {}). У файлі-скриптіdeclare module 'vue'замінить оголошення модуля замість доповнення - і всі типи Vue зникнуть. Це дзеркальна протилежність ситуації зdeclare module '*.svg', якому, навпаки, потрібен скрипт; - назва модуля має точно збігатися з тим, що імпортують (
'vue', а не'@vue/runtime-core', якщо бібліотека радить саме'vue'); - доповнювати можна лише існуючі інтерфейси - нові експорти чи нові модулі так не додаються;
- файл має потрапити в компіляцію (
includeуtsconfig).
Бібліотеки часто проєктують такі точки розширення навмисно: порожній інтерфейс-«реєстр», який користувач доповнює, - і всі API бібліотеки автоматично отримують точні типи (події, маршрути, теми, сховища).
Пастка: доповнення діють глобально для всієї програми. Поле, додане до Request у одному місці, видно всюди - тож тип має бути чесним (user?: User, а не user: User, якщо middleware автентифікації працює не на всіх маршрутах).
Докладніше в документації: Злиття оголошень: доповнення модуля
Інколи значення справді глобальне: дані, які сервер вбудовує в сторінку (window.App = {...} з Blade), скрипт аналітики, змінні середовища збирача. TypeScript треба про них розповісти.
Доповнення Window з файлу-модуля:
// src/types/global.d.ts
import type { User } from '../models';
declare global {
interface Window {
App: {
locale: string;
user: User | null;
};
dataLayer: unknown[];
}
}
export {};
declare global працює лише у файлі-модулі (з import/export). export {} наприкінці перетворює файл на модуль, якщо інших імпортів немає.
У файлі-скрипті (без імпортів) глобальні оголошення пишуть без обгортки:
// globals.d.ts
interface Window {
dataLayer: unknown[];
}
declare const __APP_VERSION__: string; // значення, підставлене збирачем (define у Vite)
Змінні середовища Vite:
// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_NAME: string;
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Тепер import.meta.env.VITE_API_URL має тип рядка, а друкарська помилка в назві - помилку компіляції.
process.env у Node.js - через простір імен NodeJS:
declare global {
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
NODE_ENV: 'development' | 'production' | 'test';
}
}
}
Пастки:
- оголошення - не гарантія. TypeScript повірить, що
window.App.userіснує, навіть якщо сервер його не передав. Для даних ззовні краще перевірка під час виконання (схема Zod) і чесні типи з| undefined; - змінні середовища оголошені як
string, але під час виконання можуть бути відсутні - валідація конфігурації при старті надійніша за самі типи; - файл не в
include- найчастіша причина, чому оголошення «не бачить» компілятор; varуdeclare globalдодає властивість і вglobalThis, аlet/const- ні; дляglobalThis.xпотрібен самеvar.
Докладніше в документації: Злиття оголошень: глобальне доповнення
Якщо в пакета немає власних типів і пакета @types/..., TypeScript повідомляє «Could not find a declaration file for module 'tiny-slug'» і вважає імпорт any (або дає помилку в строгому режимі).
Крок 1 - оголошення модуля в проєкті:
// src/types/tiny-slug.d.ts
declare module 'tiny-slug' {
export interface SlugOptions {
separator?: string;
lower?: boolean;
}
export default function slugify(text: string, options?: SlugOptions): string;
export function isSlug(value: string): boolean;
}
Файл має бути скриптом (без import/export на верхньому рівні) і потрапляти в include.
Крок 2 - описати реальну форму експорту. Тут найчастіше помиляються:
- ESM
export default- як у прикладі; - CommonJS
module.exports = fn- у декларації цеexport = fn:
declare module 'legacy-lib' {
function legacy(input: string): string;
namespace legacy {
const version: string;
}
export = legacy;
}
Імпортувати такий модуль: import legacy from 'legacy-lib' (з esModuleInterop, який у TypeScript 6/7 завжди увімкнений).
Як перевірити форму - подивитися в код пакета (main/exports у його package.json) і що реально повертає require/import у Node.js.
Крок 3 - описувати лише використане. Не обов'язково типізувати весь API бібліотеки - досить того, що викликає ваш код. Решту можна додати пізніше.
Швидкий тимчасовий варіант:
declare module 'tiny-slug'; // усе з пакета - any
Помилка зникає, але й перевірки теж. Варто лише як тимчасовий захід.
Типи для глобальної бібліотеки (підключена через <script>, створює window.Chart) - declare const Chart: ... у глобальному .d.ts.
Що далі:
- якщо декларації якісні й бібліотека популярна - запропонувати їх у DefinitelyTyped (пакет
@types/...), щоб скористалися інші; - ще краще - запропонувати PR у саму бібліотеку з типами або JSDoc-анотаціями (TypeScript уміє генерувати
.d.tsз JavaScript з JSDoc); - перевіряти, чи не з'явилися власні типи в новій версії пакета: тоді локальні декларації треба видалити, бо вони перекриватимуть справжні.
Обидві опції закривають «дірки», які лишає навіть strict, тому їх часто вмикають додатково.
noUncheckedIndexedAccess - доступ за індексом чи довільним ключем може повернути undefined:
const items: string[] = [];
const first = items[0]; // без опції: string, з опцією: string | undefined
first.toUpperCase(); // з опцією: помилка 'first' is possibly 'undefined'
const prices: Record<string, number> = {};
prices['coffee'].toFixed(2); // з опцією: помилка
Без опції TypeScript вважає, що елемент масиву чи значення словника завжди є, - і items[0] на порожньому масиві падає під час виконання.
Поведінка, яку варто знати:
for...of,map,forEachне потребують перевірок - елемент там точно існує;- кортежі з відомою довжиною (
[string, number]) не уражені; Map.get()і так повертаєT | undefinedнезалежно від опції.
exactOptionalPropertyTypes - розрізняє «властивості немає» і «властивість є і дорівнює undefined»:
type Options = { theme?: 'dark' | 'light' };
const a: Options = {}; // гаразд
const b: Options = { theme: undefined }; // з опцією: помилка
Це важливо, бо в JavaScript ці стани поводяться по-різному: 'theme' in options, Object.keys, розгортання { ...defaults, ...options } - явний undefined перезапише значення за замовчуванням. Якщо undefined справді допустиме, його вказують явно: theme?: 'dark' | 'light' | undefined.
Чому вони не в strict:
- ціна для наявного коду: увімкнення в старому проєкті дає сотні помилок, частина з яких - перевірки там, де розробник «знає», що значення є;
- шум:
arr[i]у звичайному цикліforз індексом теж вимагає перевірки чи!; - сумісність з бібліотеками:
exactOptionalPropertyTypesвиявляє розбіжності в типах сторонніх пакетів.
Рекомендація: у нових проєктах вмикати обидві (пресет @tsconfig/strictest так і робить). У наявних - noUncheckedIndexedAccess у першу чергу: він ловить реальні помилки «undefined is not an object».
moduleResolution визначає, як TypeScript шукає файл за рядком в import - і тим самим, чи збігатиметься його уявлення з тим, як код справді виконається.
bundler - для коду, який збирає Vite, webpack, esbuild чи виконує Bun:
import { formatPrice } from './utils'; // без розширення - гаразд
import Button from '@/components/Button.vue';
- розширення файлів можна не вказувати;
- підтримує поле
exportsуpackage.jsonзалежностей; - поєднується з
module: "esnext"або"preserve"(з TypeScript 6.0 - і зcommonjs).
nodenext - для коду, який напряму виконує Node.js (сервер, CLI, бібліотеки для Node):
import { formatPrice } from './utils.js'; // розширення обов'язкове
- точно моделює Node.js: ES-модуль чи CommonJS визначається полем
"type"уpackage.jsonі розширенням (.mts,.cts); - розширення обов'язкові у відносних імпортах ES-модулів, як вимагає Node.js;
- іде в парі з
module: "nodenext".
Чому розширення .js, якщо файл .ts: TypeScript не переписує шляхи в імпортах, а після компіляції поруч буде utils.js. Для коду, що виконується через вбудоване зняття типів Node.js, використовують .ts в імпортах разом з опцією rewriteRelativeImportExtensions (для генерації .js) або allowImportingTsExtensions (якщо JavaScript не генерується).
Що обрати:
| Код | moduleResolution |
|---|---|
| фронтенд на Vite (Laravel + Vue/React) | bundler |
| сервер чи скрипти, що запускає Node.js | nodenext |
| бібліотека для npm | nodenext - так перевірка суворіша й результат працює всюди |
Застарілі варіанти: node (він же node10) моделював Node.js 10 і не знав про exports; classic - алгоритм з часів до Node.js. Обидва в TypeScript 7 видалено - помилка «Option 'moduleResolution=node10' has been removed».
Ознака неправильного вибору: TypeScript без помилок перевіряє імпорт, який падає під час виконання (або навпаки). bundler для коду, що запускає Node.js, - типова причина.
Vite, esbuild, oxc, swc і Node.js перетворюють TypeScript на JavaScript по одному файлу і без перевірки типів - просто прибирають анотації. Це швидко, але такий інструмент не бачить інших файлів проєкту. Частина конструкцій TypeScript без знання інших файлів перетворюється неправильно.
Головна проблема - імпорт типу:
// types.ts
export type User = { id: number };
// app.ts
import { User } from './types';
Компілятор TypeScript знає, що User - тип, і прибере імпорт. Інструмент, що бачить лише app.ts, цього не знає: залишить import { User } from './types' - і в браузері помилка «The requested module does not provide an export named 'User'».
isolatedModules - TypeScript попереджає про код, який неможливо безпечно перетворити по одному файлу: реекспорт типу без type, const enum між файлами, файли без імпортів і експортів.
verbatimModuleSyntax - строгіший і простіший підхід: імпорти лишаються в JavaScript рівно так, як написані, крім позначених type. Тому тип треба явно позначити:
import type { User } from './types';
import { fetchUser, type UserFilter } from './api';
З опцією TypeScript видасть помилку на import { User }, якщо User - лише тип.
Що обрати: у сучасних проєктах - verbatimModuleSyntax: true. Він робить поведінку однаковою для tsc, збирачів і Node.js і замінює старіші importsNotUsedAsValues і preserveValueImports. Шаблони Vite вмикають його за замовчуванням.
Наслідки, які варто знати:
- імпорти з побічними ефектами зберігаються:
import './styles.css'нікуди не зникне; import typeне виконує модуль - якщо вам потрібен побічний ефект модуля, потрібен звичайний імпорт;- з CommonJS опція змушує писати
import x = require('...')для CommonJS-виводу - у кодовій базі на ES-модулях це не відчувається; - лінтер (
@typescript-eslint/consistent-type-imports) автоматично виправляє імпорти, тож перехід на велику кодову базу - один прогін автовиправлення.
Зв'язок з Node.js: вбудоване виконання TypeScript у Node.js теж прибирає лише імпорти з type - без verbatimModuleSyntax такі помилки виявляться лише під час запуску.
Vite лише прибирає типи - перетворює TypeScript на JavaScript без перевірки. Код з помилкою типу const n: number = 'text' спокійно збереться й запуститься. Це навмисне рішення: перевірка типів - повільна операція, що потребує аналізу всього проєкту, а Vite перетворює файли по одному за мілісекунди.
Розподіл обов'язків:
- редактор (мовний сервер TypeScript, для Vue - розширення Vue - Official) показує помилки під час написання коду;
- окрема команда перевірки - у збірці й CI.
Типова конфігурація package.json:
{
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit"
}
}
tsc --noEmit- перевірити типи без генерації файлів;- для Vue -
vue-tsc --noEmit: звичайнийtscне розуміє.vue-файли й не перевіряє шаблони; - для проєкту з кількома
tsconfig(застосунок і конфіги Node) -tsc -b.
Якщо перевірка типів у build надто сповільнює збірку, її виносять в окремий крок CI, що виконується паралельно зі збиранням.
Помилки типів під час розробки в браузері: плагін vite-plugin-checker запускає перевірку в окремому процесі й показує помилки поверх сторінки.
TypeScript 7 змінює баланс. Нативний компілятор перевіряє великий проєкт у 8-12 разів швидше - перевірка в build і в режимі --watch стає майже непомітною. Але TypeScript 7.0 ще не має програмного API, а інструменти, що його використовують (typescript-eslint підтримує лише TypeScript до 6.x, vue-tsc теж працює через API компілятора), потребують TypeScript 6 - тому в проєкті може знадобитися пакет сумісності @typescript/typescript6 поряд із TypeScript 7.
Наслідки «перевірки лише в редакторі»:
- помилки в файлах, які ніхто не відкривав, лишаються непоміченими;
- зміна типу в одному місці ламає десятки інших файлів - редактор покаже це лише у відкритих;
- тому перевірка в CI обов'язкова, навіть якщо в команді всі користуються редактором з підтримкою TypeScript.
Обмеження, що випливають з покофайлового перетворення, - isolatedModules/verbatimModuleSyntax у tsconfig.json, щоб TypeScript попереджав про конструкції, які Vite перетворить неправильно.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії