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

Як зробити типізований builder чи fluent API, що накопичує інформацію в типі?

У fluent API (будівник запитів, конструктор форм, конфігурація) кожен виклик повертає об'єкт для наступного кроку. З генеріками кожен крок може уточнювати тип результату.

Приклад - запит, що знає, які поля вибрано:

type User = { id: number; name: string; email: string; passwordHash: string };

class Query<T extends object, Selected extends keyof T = never> {
  constructor(private readonly fields: ReadonlyArray<keyof T> = []) {}

  select<K extends keyof T>(...keys: K[]): Query<T, Selected | K> {
    return new Query<T, Selected | K>([...this.fields, ...keys]);
  }

  async get(): Promise<Array<Pick<T, Selected>>> {
    /* виконати запит з this.fields */
    return [];
  }
}

const users = await new Query<User>().select('id').select('name', 'email').get();
// users: Array<{ id: number; name: string; email: string }>

users[0].passwordHash;   // помилка: поле не вибрано
new Query<User>().select('nope');   // помилка: такого поля немає

Ключова ідея: генерічний параметр (Selected) - «акумулятор». Кожен метод повертає новий тип з доповненим акумулятором (Selected | K). Через це методи мають повертати новий екземпляр з новим типом, а не this.

Обов'язкові кроки й порядок викликів - так само через параметри-прапорці:

class RequestBuilder<HasUrl extends boolean = false> {
  declare private readonly hasUrl: HasUrl;   // «фантомне» поле: лише для типів

  url(value: string): RequestBuilder<true> { /* ... */ return this as unknown as RequestBuilder<true>; }

  send(this: RequestBuilder<true>): Promise<Response> { /* ... */ }
}

new RequestBuilder().send();               // помилка: спочатку url()
new RequestBuilder().url('/api').send();   // ок

Параметр this у методі обмежує, на якому «етапі» метод доступний. Пастка: без поля hasUrl параметр HasUrl ніде не використовується в структурі класу - і через структурну типізацію RequestBuilder<false> вважається сумісним з RequestBuilder<true>, тож заборона мовчки не працює. Генерічний параметр-«прапорець» має бути частиною форми типу.

Де це використовується: Drizzle і Kysely (типізовані SQL-запити), tRPC, Zod (z.object(...).extend(...) накопичує форму), конструктори форм.

Ціна й межі:

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

Такий підхід виправданий у бібліотеках і спільних інструментах, які використовують багато разів, - там вкладення в типи окупається.

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

Перевір себе

20 випадкових питань за спробу, після завершення - розбір кожної помилки

Схожі питання