Цей документ містить практичне резюме та аналіз результатів Завдання 1 (index.day_8.ts), присвяченого реалізації власного механізму фільтрації полів та графа вибірки даних (Field Masking & Selection), архітектурний огляд побудови Code-First GraphQL API на базі NestJS та Apollo Server, а також вичерпні відповіді на питання для самоперевірки та технічного інтерв'ю із завдання p-2_d-3.md.
Код реалізації та перевірочні виклики знаходяться у файлі index.day_8.ts.
- Відтворити фундаментальний принцип роботи GraphQL на рівні чистих функцій TypeScript: клієнт передає маску бажаних полів (граф вибірки), а сервер повертає лише запитані дані без надлишкової інформації.
- Реалізувати строгу типізацію маски через рекурсивні зіставлені типи (Recursive Mapped Types) та умовні типи (Conditional Types).
- Забезпечити абсолютну імутабельність вхідного об'єкта
data, коректну обробку вкладених структур, масивів та захист від особливостей обробки типів у JavaScript (null,Array).
Функція applyFieldMask реалізує підхід «від клієнтського запиту»: ітерація відбувається виключно за ключами переданої маски, що оптимізує кількість перевірок у великих структурах даних.
Вхідні дані: Data (T) + Маска: FieldMask<T>
│
▼
┌────────────────────────────────────────────────────────┐
Ініціалізація:
const result = {} as Partial<T>
Ітерація по keys = Object.keys(mask)
└────────────────────────────────────────────────────────┘
│
▼
Чи існує ключ у data (key in data)?
├── НІ ──► Пропустити (continue)
└── ТАК
│
▼
Аналіз типу значення maskValue = mask[key]
┌────────────────────────┬─────────────────────────┬─────────────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
maskValue === true? isPlainObject(maskValue) isPlainObject(maskValue) Інше (false,
&& isPlainObject(dataVal)? && Array.isArray(dataVal)? undefined, null)
│ │ │ │
▼ ▼ ▼ ▼
Копіювання скаляра Рекурсивний виклик Мапінг елементів Ігнорування
result[key] = result[key] = result[key] = (поле виключається)
dataValue applyFieldMask(...) dataValue.map(...)
-
Рекурсивна типізація маски
FieldMask<T>:export type FieldMask<T> = { [K in keyof T]?: T[K] extends object ? FieldMask<T[K]> | boolean : boolean; };
- Якщо поле є об'єктом, клієнт може або передати вкладену маску
FieldMask<T[K]>, або виставитиboolean(наприклад, взяти весь об'єкт цілком або відхилити). - Для примітивних полів дозволено лише булеве значення.
- Якщо поле є об'єктом, клієнт може або передати вкладену маску
-
Захисний Type Guard для відокремлення чистих об'єктів (
isPlainObject):function isPlainObject(value: unknown): value is Record<string, any> { return typeof value === "object" && value !== null && !Array.isArray(value); }
Це запобігає класичним багам JavaScript, де
typeof null === 'object'таtypeof [] === 'object'. -
Взаємовиключні умови та збереження імутабельності: Використання суворого порівняння
maskValue === trueгарантує, що поле не буде передчасно скопійовано як об'єкт до виконання рекурсивного маскування.
📊 Фактичні результати виконання (index.day_8.ts)
При виконанні маски:
const masked = applyFieldMask(sampleReport, {
id: true,
metrics: {
totalRevenue: true,
averageCheck: true,
},
metadata: false,
});У консолі отримано чистий зріз даних, де поля category, metrics.totalOrders та блок metadata повністю відфільтровані:
{
"id": "rep-001",
"metrics": {
"totalRevenue": 54000,
"averageCheck": 450
}
}1. Як коректно відокремити звичайний вкладений об'єкт від масиву чи null під час рекурсивного обходу?
- Проблема: Оператор
typeofу JavaScript повертає рядок"object"для трьох принципово різних сутностей: звичайного літералу об'єкта{}, масиву[]та значенняnull. - Рішення: Написання надійного Type Guard:
Для ще суворішої перевірки (щоб відсіяти екземпляри
function isPlainObject(value: unknown): value is Record<string, any> { return typeof value === "object" && value !== null && !Array.isArray(value); }
Date,RegExp,Mapтощо) використовують перевірку прототипу:Object.prototype.toString.call(value) === "[object Object]";
2. Як адаптувати функцію, якщо в даних зустрічається масив об'єктів (наприклад, список товарів), до кожного елемента якого потрібно застосувати однакову маску?
- Рішення: Додати додаткову гілку обробки, яка перевіряє, чи є значення масивом (
Array.isArray(dataValue)), а значення маски — об'єктом (isPlainObject(maskValue)):Це дозволяє клієнту описати шаблон вибірки один раз (наприклад,else if (Array.isArray(dataValue) && isPlainObject(maskValue)) { result[key] = dataValue.map((item) => isPlainObject(item) ? applyFieldMask(item, maskValue as FieldMask<any>) : item ); }
{ items: { id: true, name: true } }), а сервер застосує його до кожного елемента масиву вitems.
У підході Code-First схема GraphQL автоматично генерується на основі TypeScript-класів та спеціальних декораторів @nestjs/graphql:
@ObjectType()— декларує вихідний тип (тип, який повертає сервер).@InputType()— декларує вхідний тип (параметри, які передає клієнт у запитах/мутаціях).@Field()— визначає конкретне поле, його тип та обов'язковість у схемі.
Конфігурація модуля в AppModule:
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), "src/schema.gql"),
sortSchema: true,
playground: true,
})| Критерій | @ObjectType() (Output Type) |
@InputType() (Input Type) |
|---|---|---|
| Призначення | Описує структуру даних, які сервер повертає клієнту. | Описує аргументи/параметри, які клієнт надсилає серверу. |
| Генерація в SDL | Генерує конструкцію type Name { ... } |
Генерує конструкцію input Name { ... } |
| Аргументи полів | Поля можуть приймати власні аргументи (наприклад: avatar(size: Int): String). |
Поля не можуть мати аргументів — це суто пласкі/вкладені структури значень. |
| Підтримка інтерфейсів | Може імплементувати interface та брати участь в union. |
Не може наслідувати інтерфейси чи бути частиною union-типів. |
| Циклічні посилання | Дозволені (наприклад: User посилається на Order, а Order — на User). |
Суворо заборонені специфікацією GraphQL (циклічний вхідний аргумент унеможливлює серіалізацію). |
2. Навіщо в декораторах полів явно вказувати стрілочну функцію типу на зразок @Field(() => Float), якщо в TypeScript поле вже типізовано як number?
- Невідповідність систем типів (Type Erasure & Scalar Precision):
TypeScript під час компіляції стирає типи. За допомогою
reflect-metadataNestJS може дізнатися лише те, що тип поля є базовим конструкторомNumber. Проте в системі типів GraphQL існують два різних числових скаляри:Int(ціле 32-бітне число) таFloat(число з рухомою крапкою). Явна функція@Field(() => Float)вказує компілятору схеми, який саме скаляр підставити. - Вирішення циклічних імпортів (Circular Dependency Resolution):
Стрілочна функція типу
() => CategoryMetricsє ледачою (lazy evaluation). Вона виконується не в момент завантаження файлу інтерпретатором Node.js, а пізніше — коли всі класи вже ініціалізовані в пам'яті. Це запобігає виникненню помилокReferenceError: Cannot access 'X' before initializationпри взаємних посиланнях типів.
Резолвер у NestJS (@Resolver()) відповідає концепції контролера в REST, обробляючи три типи GraphQL операцій:
@Query()— читання даних (аналог HTTP GET).@Mutation()— модифікація або запис даних (аналог POST/PUT/PATCH/DELETE).@ResolveField()— точкове обчислення значення окремого поля графа.
1. Коли виконується метод із декоратором @ResolveField(): для кожного елемента масиву окремо чи для всього масиву одразу?
-
Механізм виконання: Метод
@ResolveField()викликається індивідуально для кожного елемента масиву, якщо це поле було запрошено клієнтом у запиті. -
Анатомія процесу: Якщо основний запит повертає 100 звітів
[CategoryReport], а клієнт запросив полеformattedSummary:- Метод
@Query()виконується 1 раз і повертає масив із 100 об'єктів. - Метод
@ResolveField('formattedSummary')викликається 100 разів (для кожного батьківського елемента@Parent()).
- Метод
-
⚠️ Архітектурний ризик (Проблема N+1): Якщо всередині@ResolveField()виконується асинхронний запит до бази даних (наприклад, пошук профілю автора для кожного звіту), це призведе до$1 + N$ звернень до БД. Для вирішення цієї проблеми застосовують бібліотеку DataLoader (пакетування та кешування запитів).
2. Яким чином глобальний ValidationPipe підключається для валідації полів усередині InputType у додатку з GraphQL?
У додатках на базі NestJS з GraphQL глобальний пайп валідації працює прозоро для всіх InputType, оскільки NestJS інтегрує пайпи у життєвий цикл виконання резолверів:
- Підключення в
main.ts:app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), );
- Або реєстрація через модуль за допомогою
APP_PIPE:@Module({ providers: [ { provide: APP_PIPE, useClass: ValidationPipe, }, ], }) export class AppModule {}
Коли запит надходить на резолвер, NestJS автоматично інстанціює клас DTO (DateRangeInput) за допомогою class-transformer та валідує правила декораторів (@IsOptional(), @IsDate()) за допомогою class-validator. Якщо валідація не проходить, клієнт отримує стандартну GraphQL помилку з кодом BAD_USER_INPUT.
1. Over-fetching та Under-fetching: Як саме GraphQL усуває обидві ці проблеми, типові для класичного REST API?
REST: Жорсткі фіксовані ендпоінти
┌───────────────────────────┐ ┌───────────────────────────────────────┐
│ GET /api/users/1 │ ──────► │ Повертає 50 полів (потрібно лише 2) │ ──► OVER-FETCHING
└───────────────────────────┘ └───────────────────────────────────────┘
┌───────────────────────────────────────┐
┌───────────────────────────┐ │ Потрібні замовлення? │
│ GET /api/users/1/orders │ ──────► │ Робимо 2-й запит до мережі │ ──► UNDER-FETCHING
└───────────────────────────┘ │ Потрібні деталі товарів? │ (Waterfalls)
┌───────────────────────────┐ │ Робимо ще N запитів до /products/:id │
│ GET /api/products/:id │ ──────► │ │
└───────────────────────────┘ └───────────────────────────────────────┘
- У REST: Ендпоінт повертає фіксовану структуру даних, визначену бекенд-розробником. Наприклад, щоб відобразити ім'я автора коментаря в мобільному додатку, клієнт викликає
GET /api/users/:idі отримує масивний JSON із адресою, хешем пароля, історією логінів, налаштуваннями тощо (десятки кілобайтів зайвих даних). - Наслідки: Марнотратне споживання мобільного трафіку, навантаження на мережевий стек, підвищені витрати часу процесора на парсинг великих JSON у клієнтських додатках.
- Рішення в GraphQL: Клієнт формує запит, де явно вказує лише необхідні поля:
Сервер відправляє JSON виключно з полем
query { user(id: "1") { name } }
name.
- У REST: Один ендпоінт рідко містить усі дані, необхідні для рендерингу цілісного екрана. Щоб відобразити замовлення, клієнт спочатку запитує профіль користувача, потім список його замовлень, а потім для кожного замовлення — опис товару. Виникає ефект «мережевого водоспаду» (Network Waterfall / N+1 Round-trips).
- Наслідки: Велика затримка рендерингу інтерфейсу (Latency), критична для мобільних пристроїв з нестабільним зв'язком.
- Рішення в GraphQL: Клієнт за один HTTP-запит витягує весь граф взаємопов'язаних сутностей:
Сервер самостійно збирає ці дані через резолвери і віддає за один round-trip.
query GetDashboard { user(id: "1") { name orders { id totalAmount items { productName } } } }
| Параметр | REST API | GraphQL |
|---|---|---|
| Хто контролює форму відповіді | Сервер (фіксований контракт DTO) | Клієнт (декларативний граф вибірки) |
| Кількість мережевих запитів | Часто |
Завжди 1 комбінований запит |
| Еволюція API | Версіонування шляхів (/v1/, /v2/) |
Безверсійне API (депрекація полів через @deprecated) |
2. Кешування на рівні мережі: Чому стандартні заголовки кешування (наприклад, Cache-Control) практично не працюють "з коробки" для GraphQL-запитів?
- Уніфікована точка входу (Single Endpoint) та HTTP POST:
- У REST кожен ресурс має унікальний глобальний ідентифікатор URI (
GET /api/products/42,GET /api/categories). Мережеві проксі-сервери, CDN (Cloudflare, Fastly, AWS CloudFront) та браузери кешують відповіді за ключем, який є комбінацієюMETHOD + URL. - У GraphQL всі запити відправляються на один єдиний URI — зазвичай
POST /graphql. Згідно зі специфікацією HTTP (RFC 7231 / RFC 9110), методPOSTза замовчуванням вважається неідемпотентним і не підлягає стандартному кешуванню проміжними вузлами.
- У REST кожен ресурс має унікальний глобальний ідентифікатор URI (
- Різна семантика за однакової URL-адреси:
Два клієнти можуть надіслати
POST /graphql: один запитує лише назву товару, а інший — залишки на складі та ціну. URL однаковий, а вміст тіла запиту і відповіді — принципово різний. Традиційний кеш не аналізує JSON-тіло запиту. - Композитність відповіді (Mixed Freshness):
В одному GraphQL-запиті можуть поєднуватися статичні глобальні дані (наприклад, опис категорії товарів, що змінюється раз на місяць) та ультрадинамічні персональні дані (наприклад, кількість бонусів користувача). Неможливо призначити один загальний HTTP-заголовок
Cache-Control: max-age=3600на всю відповідь, оскільки це призведе до показу застарілих або чужих персональних даних.
REST: Кешування за унікальним URL
GET /api/products/42 ────► [ CDN Cache Key: "GET /api/products/42" ] ────► HIT (200 OK з кешу)
GraphQL: Спільний ендпоінт, тіло в POST
POST /graphql { query: { a } } ────► [ CDN: Метод POST — кешування вимкнено ] ──► Йде на бекенд
POST /graphql { query: { b } } ────► [ CDN: Метод POST — кешування вимкнено ] ──► Йде на бекенд
- Automatic Persisted Queries (APQ):
- Клієнт генерує SHA-256 хеш від тексту запиту GraphQL.
- Перший запит відправляється як
GET /graphql?hash=abcdef123456.... - Оскільки це HTTP
GETіз детермінованими query-параметрами, CDN та проксі-сервери можуть кешувати його як звичайний REST-запит!
- Директива
@cacheControlта Cache Scope: Apollo Server підтримує розмітку схеми:Сервер автоматично вираховує мінімальний спільний знаменникtype Product { id: ID! title: String! @cacheControl(maxAge: 86400, scope: PUBLIC) userCartCount: Int! @cacheControl(maxAge: 0, scope: PRIVATE) }
max-ageдля всієї відповіді та формує відповідний заголовокCache-Control. - Клієнтське нормалізоване кешування (Normalized Cache):
Клієнтські бібліотеки (Apollo Client, Relay, Urql) кешують дані всередині додатку не за запитами, а за сутностями, розбиваючи відповідь за ключем
__typename:id.
3. Статус-коди відповідей: Чому відповіді GraphQL майже завжди приходять зі статусом 200 OK, навіть якщо виконання операції завершилося помилкою? Де саме міститься інформація про збій?
У GraphQL існує фундаментальне архітектурне розмежування між транспортним рівнем (HTTP Transport Layer) та рівнем виконання GraphQL (Execution Layer):
- HTTP 200 OK повідомляє клієнту лише одне: «Мережеве з'єднання успішне, сервер отримав повідомлення, успішно розпарсив вхідний запит і повернув валідний GraphQL-документ».
- Що саме сталося всередині самого запиту (чи знайшовся запис у БД, чи відмовив сторонній мікросервіс) — регулюється внутрішнім протоколом GraphQL.
На відміну від REST, де помилка одного сервісу призводить до статусу 500 Internal Server Error і клієнт не отримує взагалі нічого, GraphQL підтримує повернення часткових результатів:
{
"data": {
"product": {
"id": "prod-100",
"name": "Ноутбук Pro",
"reviews": null
}
},
"errors": [
{
"message": "Мікросервіс відгуків тимчасово недоступний (Timeout)",
"locations": [{ "line": 5, "column": 7 }],
"path": ["product", "reviews"],
"extensions": {
"code": "SERVICE_UNAVAILABLE",
"timestamp": "2026-03-08T12:00:00Z"
}
}
]
}Згідно зі специфікацією GraphQL Specification (Section 7: Response):
data: Містить успішно отримані дані. Якщо резолвер поля викинув виняток і це поле булоnullable, поле отримує значенняnull, а решта графа повертається клієнту цілою!errors: Масив об'єктів помилок. Кожна помилка містить:message— людиночитаний опис збою.path— точний шлях у дереві відповіді до поля, де стався виняток (["product", "reviews"]).locations— номер рядка та стовпчика у вхідному GraphQL-запиті.extensions— службовий об'єкт для передачі додаткових метаданих (стектрейс, машинний код помилкиINTERNAL_SERVER_ERROR,UNAUTHENTICATED).
Note
Коли GraphQL повертає не 200 OK? За сучасною специфікацією GraphQL over HTTP:
- 400 Bad Request: Якщо вхідний запит має синтаксичні помилки (Syntax Error) або не пройшов валідацію схеми (невідоме поле, відсутній обов'язковий аргумент).
- 401/403: Якщо HTTP-запит був заблокований гвардом або middleware ще до передачі в рушій GraphQL.
- 500: Якщо стався критичний крах самого Apollo Server.
4. Code-First vs Schema-First: Які плюси та потенційні мінуси має підхід Code-First у NestJS порівняно зі створенням файлів .graphql вручну?
В екосистемі NestJS існує два принципових підходи до розробки GraphQL API:
- Code-First: Розробник пише класи TypeScript, декоруючи їх (
@ObjectType(),@Field()). Файл схеми (schema.gql) генерується фреймворком автоматично під час компіляції/старту. - Schema-First: Розробник спочатку пише чистий контракт схеми у файлі
.graphqlмовою SDL (Schema Definition Language), а потім фреймворк генерує під нього інтерфейси TypeScript або мапить їх на резолвери.
Code-First (Single Source of Truth: TypeScript)
┌────────────────────────────────┐ ┌─────────────────────────────┐
│ Класи TS + Декоратори NestJS │ ──(Генерація)─► │ Фінальна схема schema.gql │
│ CategoryReport, @Field() │ │ (Артефакт для клієнтів) │
└────────────────────────────────┘ └─────────────────────────────┘
Schema-First (Contract-Driven / API-First)
┌────────────────────────────────┐ ┌─────────────────────────────┐
│ Рукописний файл schema.graphql │ ──(Генерація)─► │ Інтерфейси TS (ts-morph) │
│ type CategoryReport { ... } │ │ + Ручне мапіння резолверів │
└────────────────────────────────┘ └─────────────────────────────┘
| Критерій | Code-First | Schema-First |
|---|---|---|
| Джерело правди | TypeScript-код (єдине джерело правди). | Файли .graphql (SDL). |
| Типобезпека | Абсолютна (100%). Неможливо випадково повернути поле, якого немає в моделі. | Можливий розсинхрон, якщо генератор типів не перезапустився. |
| Рефакторинг в IDE | Швидкий та надійний: комбінація F2 (Rename Symbol) автоматично змінює ім'я по всьому бекенду. |
Важчий: зміна поля в SDL вимагає ручного оновлення всіх резолверів і DTO. |
| Валідація DTO | Безшовна: один і той самий клас використовує і декоратори GraphQL (@Field), і валідатори (@IsString). |
Подвійна робота: потрібно описати типи в SDL, а потім окремо створити класи DTO для class-validator. |
| Узгодження контракту | Контракт з'являється лише після написання коду бекенда. | Ідеально для крос-команд: фронтенд і бекенд узгоджують схему до написання коду. |
| Чистота коду | Чистий код резолверів без нагромадження метаданих. | |
| Швидкість старту застосунку | Трохи повільніший (вимагає рефлексії типів у рантаймі та побудови схеми). | Швидкий парсинг готових текстових файлів SDL. |
- Code-First (NestJS) — стандарт де-факто для сучасних бекендів на TypeScript. Він усуває дублювання типів, забезпечує безшовну інтеграцію з
class-validatorта гарантує безпомилковий рефакторинг. - Schema-First найкраще підходить для великих розподілених систем, де розробка ведеться різними мовами (наприклад, Go + TypeScript) або де спочатку потрібно зафіксувати спільний контракт між кількома незалежними командами (Design by Contract).