Расширение класса Dexie для типизированных таблиц

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

Базовая модель расширенного класса Dexie

Классическая схема начинается с объявления доменных интерфейсов и наследования Dexie:

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

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

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

Далее создаётся класс базы данных, расширяющий Dexie, с явной типизацией таблиц:

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

  constructor() {
    super("AppDatabase");

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

Здесь ключевой момент заключается в связке Table<Entity, PrimaryKey>, которая позволяет TypeScript выводить корректные типы для всех операций CRUD.

Жёсткая типизация через Table<T, Key>

Внутренний механизм Dexie.js опирается на обобщённый тип Table<T, TKey>, где:

  • T — модель данных
  • TKey — тип первичного ключа

Это позволяет получать строгую проверку при вызове методов:

const db = new AppDatabase();

await db.users.add({
  id: 1,
  name: "Alex",
  email: "alex@mail.com"
});

Любое несоответствие структуре User приводит к ошибке компиляции, что существенно снижает количество runtime-ошибок.

Расширение класса дополнительными методами

Расширение Dexie-класса позволяет добавлять доменные методы, инкапсулируя бизнес-логику прямо внутри слоя доступа к данным.

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

  constructor() {
    super("AppDatabase");
    this.version(1).stores({
      users: "id, email"
    });
  }

  async getUserByEmail(email: string) {
    return this.users.wh ere("email").equals(email).first();
  }

  async createUser(user: User) {
    return this.users.add(user);
  }
}

Такой подход превращает экземпляр базы данных в полноценный repository-layer объект.

Проблема типизации this и контекста класса

При добавлении методов важно учитывать контекст this, поскольку Dexie использует собственную внутреннюю инициализацию таблиц после конструктора. Поэтому доступ к таблицам возможен только после super() и объявления версий.

Ошибочная попытка обращения к таблице до инициализации:

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

  constructor() {
    super("BadDB");
    this.users.add({ id: 1, name: "", email: "" }); // недопустимо
  }
}

Корректная работа всегда предполагает использование методов после инициализации экземпляра.

Module Augmentation для глобальной типизации Dexie

При масштабных приложениях часто требуется расширять сам тип Dexie через module augmentation, чтобы интегрировать дополнительные поля или плагины.

declare module "dexie" {
  interface Dexie {
    readonly versionNumber: number;
  }
}

После этого расширение становится частью типовой системы всего проекта.

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

Более строгий подход заключается в описании всей схемы базы через единый интерфейс:

interface DBSchema {
  users: User;
  orders: Order;
}

И последующем связывании с Dexie:

class AppDatabase extends Dexie {
  users!: Table<DBSchema["users"], number>;
  orders!: Table<DBSchema["orders"], number>;
}

Такой способ снижает дублирование типов и обеспечивает централизованное управление схемой.

Добавление типизированных транзакционных методов

Dexie.js поддерживает транзакции, которые можно оборачивать в строго типизированные методы класса:

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

  constructor() {
    super("AppDatabase");
    this.version(1).stores({
      users: "id, email",
      orders: "id, userId"
    });
  }

  async createUserWithOrder(user: User, order: Order) {
    return this.transaction("rw", this.users, this.orders, async () => {
      await this.users.add(user);
      await this.orders.add(order);
    });
  }
}

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

Переопределение поведения через наследование Dexie

Наследование позволяет внедрять общие механизмы логирования, кеширования или валидации:

class BaseDatabase extends Dexie {
  protected log(query: string) {
    console.log("DB QUERY:", query);
  }
}

class AppDatabase extends BaseDatabase {
  users!: Table<User, number>;

  constructor() {
    super("AppDatabase");

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

  async findUser(id: number) {
    this.log(`users.get(${id})`);
    return this.users.get(id);
  }
}

Такой подход полезен при построении архитектуры с единым слоем доступа к данным.

Интеграция строгих ключей и индексов

При расширении Dexie-класса важно синхронизировать типы с индексами схемы stores. Несоответствие индексов приводит к логическим ошибкам, которые TypeScript не всегда способен отловить.

this.version(1).stores({
  users: "++id, email, name",
});

Здесь ++id обозначает автоинкремент, а тип number должен отражать это поведение:

users!: Table<User, number>;

Композиция вместо наследования

В более сложных архитектурах допускается оборачивание Dexie-экземпляра в сервисный класс, но при этом сохраняется типизированный доступ:

class DatabaseService {
  private db: AppDatabase;

  constructor() {
    this.db = new AppDatabase();
  }

  get users() {
    return this.db.users;
  }

  async allUsers() {
    return this.db.users.toArray();
  }
}

Такой подход снижает связанность, сохраняя преимущества строгой типизации Dexie.js.

Обобщённые фабрики баз данных

Для масштабируемых приложений часто применяется фабрика, создающая типизированные экземпляры Dexie:

function createDatabase<TSchema>() {
  class GenericDB extends Dexie {
    constructor(name: string) {
      super(name);
    }
  }

  return GenericDB;
}

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

Практика безопасного расширения Dexie

Расширение класса всегда должно учитывать:

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

Dexie.js при этом выступает как слой абстракции над IndexedDB, а расширение класса превращает его в типизированный persistence-layer, пригодный для крупных приложений с предсказуемой структурой данных