Типизация схемы через интерфейсы

Типизация схемы в Dexie.js в TypeScript строится вокруг строгого описания структуры базы данных через интерфейсы, где каждая таблица получает явную модель данных, а вся схема связывается в единую типобезопасную конфигурацию. Такой подход позволяет вывести работу с IndexedDB на уровень статической проверки, устраняя целый класс ошибок, связанных с несоответствием структуры данных и запросов.

Типизация начинается с описания сущностей предметной области. Каждая таблица соответствует отдельному интерфейсу, который фиксирует форму записи.

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

interface Order {
  id: number;
  userId: number;
  total: number;
  createdAt: Date;
}

Ключевой момент заключается в том, что интерфейс отражает именно хранимое состояние, а не бизнес-логику. Это позволяет использовать один и тот же тип как для вставки, так и для чтения данных.

Описание схемы базы через расширение Dexie

Типизация базы данных в Dexie.js строится через класс, наследующий Dexie, где таблицы описываются как Table<T, Key>.

import { Dexie, Table } fr om 'dexie';

class AppDatabase extends Dexie {
  users!: Table<User, number>;
  orders!: Table<Order, number>;

  constructor() {
    super('AppDatabase');

    this.version(1).stores({
      users: 'id, email, age',
      orders: 'id, userId, createdAt'
    });
  }
}

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

  • User и Order фиксируют структуру записи
  • number задаёт тип первичного ключа
  • Table<T, K> связывает схему и API таблицы

Связка схемы stores и TypeScript-интерфейсов

Схема, передаваемая в stores, остаётся строковой, но её смысл синхронизируется с интерфейсами вручную. Типизация не анализирует строку индексов автоматически, поэтому поддержание консистентности становится архитектурной задачей.

this.version(1).stores({
  users: 'id, email, age',
});

Каждое поле индекса должно существовать в соответствующем интерфейсе. Несоответствие приводит к логическим ошибкам без компиляционных предупреждений.

Композитные ключи и типизация

При использовании составных индексов структура ключа усложняется, и типизация должна учитывать порядок и типы полей.

interface Enrollment {
  studentId: number;
  courseId: number;
  enrolledAt: Date;
}
this.version(1).stores({
  enrollments: '[studentId+courseId], enrolledAt'
});

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

  • первичный доступ — по составному ключу
  • вторичные индексы — по отдельным полям

Расширение таблиц через строгие generic-типы

Dexie.js предоставляет Table<T, Key> как основной механизм типизации операций CRUD.

async function addUser(db: AppDatabase, user: User) {
  return db.users.add(user);
}

Тип User автоматически распространяется на:

  • add
  • put
  • get
  • toArray
  • where

Это обеспечивает единообразие модели данных во всех операциях.

Типизация первичного ключа и автоинкремент

В случаях, когда ключ генерируется автоматически, используется тип number или string с возможной опциональностью поля.

interface LogEntry {
  id?: number;
  message: string;
  timestamp: number;
}
class LogDB extends Dexie {
  logs!: Table<LogEntry, number>;

  constructor() {
    super('LogDB');

    this.version(1).stores({
      logs: '++id, timestamp'
    });
  }
}

Символ ++ указывает автоинкремент, но TypeScript не выводит это автоматически, поэтому поле id часто делается необязательным.

Строгая типизация запросов через where

Типизация запросов через where зависит от того, какие поля объявлены как индексы.

db.users.where('age').above(18).toArray();

Если поле не объявлено в stores, TypeScript не сможет ограничить доступ, и возникает риск неконсистентных запросов. Поэтому схема индексов становится частью контрактной модели.

Типизация транзакций

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

db.transaction('rw', db.users, db.orders, async () => {
  const userId = await db.users.add({
    id: 1,
    name: 'Alex',
    email: 'a@a.com'
  });

  await db.orders.add({
    id: 1,
    userId,
    total: 100,
    createdAt: new Date()
  });
});

В TypeScript контекст транзакции сохраняет типы всех участвующих таблиц, предотвращая обращение к несуществующим полям или таблицам вне области транзакции.

Обобщённая модель базы данных

Для крупных проектов используется единый интерфейс базы, описывающий все таблицы сразу.

interface DatabaseSchema {
  users: User;
  orders: Order;
  logs: LogEntry;
}

Однако Dexie.js не требует этого интерфейса напрямую, поэтому он используется как архитектурный слой, а не как обязательная часть API.

Расширенные типы: проекции и частичные выборки

Типизация может быть уточнена при работе с select-подобными операциями через ручные utility-типы.

type UserPreview = Pick<User, 'id' | 'name'>;
async function getUserPreview(db: AppDatabase): Promise<UserPreview[]> {
  return db.users.toArray().then(users =>
    users.map(({ id, name }) => ({ id, name }))
  );
}

Хотя библиотека не навязывает projection typing, TypeScript позволяет строить производные модели поверх базовых интерфейсов.

Миграции и эволюция типов

При изменении схемы важно синхронизировать интерфейсы с версионностью базы.

this.version(2).stores({
  users: 'id, email, age, role'
});

Интерфейс:

interface UserV2 extends User {
  role: string;
}

Подходы к типизации миграций:

  • расширение интерфейсов через наследование
  • разделение версий моделей
  • единая модель с optional-полями

Каждый вариант влияет на стабильность API доступа к данным.

Ограничения статической типизации схемы

Типизация в Dexie.js имеет фундаментальное ограничение: строковое описание индексов не связано напрямую с TypeScript-типами. Это приводит к следующим особенностям:

  • индексы не проверяются компилятором
  • несоответствие схемы и интерфейса обнаруживается только в runtime
  • сложные индексы требуют ручной синхронизации типов

Эти ограничения компенсируются дисциплиной описания моделей и строгим разделением слоёв данных.

Унификация доступа через типизированный слой

На практике создаётся слой репозитория, который инкапсулирует работу с таблицами.

class UserRepository {
  constructor(private db: AppDatabase) {}

  getByEmail(email: string) {
    return this.db.users.where('email').equals(email).first();
  }
}

Типы интерфейсов в этом случае формируют контракт между слоями хранения и бизнес-логики, сохраняя согласованность структуры данных без необходимости ручной проверки каждой операции.