Generics в запросах и коллекциях

Dexie.js предоставляет поверх IndexedDB мощный слой типизированного доступа к данным, и именно использование generics в TypeScript превращает её из просто удобной обёртки в строго типизированное хранилище данных. Обобщения пронизывают ключевые элементы: описание таблиц, типизацию записей, результат запросов и цепочки методов Collection. Правильное использование generics позволяет добиться полной статической проверки структуры базы данных на этапе компиляции.


Базовая модель типизации базы данных

Dexie строится вокруг описания структуры базы через расширение класса Dexie. Основой является сопоставление имени таблицы и типа сущности.

interface User {
  id: number;
  name: string;
  age: number;
}

class MyDatabase extends Dexie {
  users!: Table<User, number>;

  constructor() {
    super("myDatabase");
    this.version(1).stores({
      users: "++id, name, age"
    });
  }
}

Здесь Table<User, number> — ключевой пример использования generics:

  • первый параметр User задаёт форму записи
  • второй параметр number определяет тип первичного ключа

Эта связь формирует основу типобезопасности во всех последующих операциях.


Generic-структура Table<T, Key>

Тип Table<T, Key> определяет поведение таблицы как коллекции объектов типа T, индексированных ключом Key.

Основные свойства типизации:

  • T определяет форму данных
  • Key задаёт тип primary key
  • методы CRUD автоматически наследуют T и Key

Пример операций:

await db.users.add({
  name: "Alice",
  age: 30
});

TypeScript автоматически проверяет соответствие структуры User. Отсутствие id допустимо, если ключ автогенерируемый (++id).


Влияние generics на методы таблиц

Каждый метод Table<T, Key> строго использует тип T.

Добавление данных

add(item: Omit<T, Key>): Promise<Key>

В случае автоинкремента ключ исключается из входного объекта.


Получение данных

get(key: Key): Promise<T | undefined>

Возвращаемое значение строго привязано к типу записи.


Обновление

update(key: Key, changes: Partial<T>): Promise<number>

Тип Partial<T> позволяет передавать только изменяемые поля без полной структуры объекта.


Generic-цепочки Collection

После выполнения запросов Dexie возвращает объект Collection<T>, который является ключевым элементом для построения цепочек фильтрации и сортировки.

const adults = db.users
  .where("age")
  .above(18)
  .toArray();

Здесь тип T сохраняется на всём протяжении цепочки.


where() и сохранение типа T

Метод where() не изменяет тип коллекции, но ограничивает выборку:

where(index: keyof T): Collection<T>

TypeScript гарантирует, что индекс существует в T. Попытка обратиться к несуществующему полю приводит к ошибке компиляции.


Фильтрация через Collection

Методы Collection<T> используют generics для сохранения строгой типизации на каждом шаге:

filter(fn: (value: T) => boolean): Collection<T>

Пример:

const result = db.users
  .filter(user => user.age > 18)
  .toArray();

Тип user внутри функции строго выводится как User.


Сортировка и модификация коллекций

orderBy

orderBy(index: keyof T): Collection<T>

Сортировка возможна только по ключам, определённым в типе.


limit и offset

Эти методы не изменяют тип:

limit(n: number): Collection<T>
offset(n: number): Collection<T>

Тип данных остаётся T, что важно для сохранения цепочки.


Проекции и трансформации типов

Некоторые методы позволяют изменять тип результата, что делает generics более гибкими.

map

map<U>(fn: (value: T) => U): Collection<U>

Пример:

const names = db.users
  .toCollection()
  .map(user => user.name);

Результат: Collection<string>.


modify through primary key operations

Методы вроде modify и delete не меняют тип коллекции, но оперируют Key:

modify(changes: Partial<T>): Promise<number>
delete(key: Key): Promise<void>

Compound queries и пересечение типов

При использовании нескольких условий тип T остаётся неизменным, однако логическая структура запроса становится более ограниченной.

db.users
  .where("age")
  .above(18)
  .and(user => user.name.startsWith("A"))

Здесь and сохраняет Collection<T> без изменения типа, но добавляет дополнительный предикат:

and(fn: (value: T) => boolean): Collection<T>

Типизация результата toArray и first

Финальные методы коллекций завершают цепочку и возвращают строго типизированные результаты.

toArray

toArray(): Promise<T[]>

first

first(): Promise<T | undefined>

last

last(): Promise<T | undefined>

Эти методы фиксируют типизацию, превращая ленивую коллекцию в конкретные данные.


Key ranges и обобщённые диапазоны

Dexie позволяет использовать диапазоны ключей, сохраняя тип ключа Key.

where("id")
  .between(10, 20)

Сигнатура:

between(lower: Key, upper: Key): Collection<T>

Это обеспечивает строгую проверку границ диапазона.


Generic constraints в индексированных запросах

Когда используется where, TypeScript ограничивает выбор полей только ключами T:

where(index: keyof T)

Это исключает возможность обращения к несуществующим полям и гарантирует согласованность схемы.


Комбинация нескольких generics в расширенных сценариях

В сложных архитектурах можно расширять типизацию через композицию:

type BaseEntity = {
  id: number;
};

type Auditable = {
  createdAt: Date;
  updatedAt: Date;
};

type User = BaseEntity & Auditable & {
  name: string;
};

Dexie корректно обрабатывает такие объединённые типы:

Table<User, number>

Все поля становятся частью типизированных запросов.


Async-результаты и вывод типов

Все операции Dexie асинхронны, но generics сохраняются через Promise<T>:

  • Promise<T>
  • Promise<T[]>
  • Promise<Key>

TypeScript выводит тип результата на основе исходной коллекции:

const user: User | undefined = await db.users.get(1);

Влияние inferencing на цепочки

Type inference играет ключевую роль: Dexie не требует явного указания типов на каждом шаге цепочки, но сохраняет их через generics:

const q = db.users
  .where("age")
  .above(18)
  .filter(u => u.name.length > 3)
  .map(u => ({ id: u.id, name: u.name }));

Тип результата автоматически становится:

Collection<{ id: number; name: string }>

Ограничения generics в Dexie

Несмотря на мощную типизацию, существуют ограничения:

  • структура схемы (stores) не типизируется автоматически через T
  • индексы не всегда строго проверяются на соответствие полям
  • динамическое добавление полей может нарушить строгую модель T

Эти ограничения связаны с природой IndexedDB, а не TypeScript.


Совместимость generics с транзакциями

В транзакциях типизация сохраняется без изменений:

db.transaction("rw", db.users, async () => {
  const user = await db.users.get(1);
  await db.users.put({ ...user!, name: "New" });
});

Тип Table<T, Key> не теряет информации внутри транзакционного контекста, а Collection<T> продолжает функционировать как типизированная цепочка.


Композиция generics в реальных сценариях запросов

Типичная цепочка:

const result = await db.users
  .where("age")
  .above(21)
  .filter(u => u.name.includes("a"))
  .toArray();

Тип результата:

User[]

Каждый этап сохраняет или трансформирует T, но никогда не теряет его семантику.


Типовая модель Dexie как система зависимых generics

Внутренняя структура типизации Dexie фактически представляет собой систему зависимых generics:

  • Table<T, Key> → источник типов
  • Collection<T> → потоковая трансформация
  • методы → функции преобразования T → T или T → U

Эта модель обеспечивает строгую типовую консистентность на всём пути от хранения до выборки данных