In backend development, keeping code scalable and clean is key. The Generic Repository Pattern helps by abstracting data access logic, letting you reuse code across models instead of duplicating it. With TypeScript , you can ensure type safety while handling model-specific logic. In this guide, I’ll show you how to set up a generic repository in TypeScript for reusable CRUD operations, cutting down redundancy and boosting code consistency.
Step-by-Step Guide
1. Setting Up the Base Repository
First, we’ll create a BaseRepository.ts
file. This will define a set of common methods like find
, findOne
, create
, update
, and delete
. Each of these methods will be generic enough to work with any model in your application.
import {
type Attributes,
type CountOptions,
type CreateOptions,
type DestroyOptions,
type FindOptions,
type Model,
type ModelStatic,
type UpdateOptions,
} from "sequelize";
import {
type Col,
type Fn,
type Literal,
type MakeNullishOptional,
} from "sequelize/types/utils";
export abstract class BaseRepository<T extends Model> {
constructor(protected model: ModelStatic<T>) {}
async find(options?: FindOptions<Attributes<T>>): Promise<T[]> {
return await this.model.findAll(options);
}
async findOne(options?: FindOptions<Attributes<T>>): Promise<T | null> {
return await this.model.findOne(options);
}
async delete(options?: DestroyOptions<Attributes<T>>): Promise<number> {
return await this.model.destroy(options);
}
async update(
data: {
[key in keyof Attributes<T>]?:
| Fn
| Col
| Literal
| Attributes<T>[key]
| undefined;
},
options: Omit<UpdateOptions<Attributes<T>>, "returning"> & {
returning: Exclude<
UpdateOptions<Attributes<T>>["returning"],
undefined | false
>;
}
): Promise<[affectedCount: number, affectedRows: T[]]> {
return await this.model.update(data, options);
}
async create(
data: MakeNullishOptional<T["_creationAttributes"]>,
options?: CreateOptions<Attributes<T>> | undefined
): Promise<T> {
return await this.model.create(data, options);
}
}
2. Extending the Base Repository
Now that we have our BaseRepository
, the next step is to create specific repositories for each model by extending this base class. This way, we can focus on model-specific logic, while reusing the generic data access methods from the base repository.Here’s an example of how to create a CustomerRepository
that extends the BaseRepository
.
import { type Customer } from "./models/Customer";
import { BaseRepository } from "./BaseRepository";
class CustomerRepository extends BaseRepository<Customer> {
// You can add customer-specific methods here if needed
}
export default CustomerRepository;
If you’re curious how the Customer
models look like, here is a simplified version of a model generated using the npm package sequelize-auto
. This package automatically generates Sequelize models based on your existing database structure, which can be a huge time-saver when building your application.
Here’s a simple model example:
import { DataTypes, Model, Optional } from "sequelize";
export interface CustomerAttributes {
id: string;
name: string;
email?: string;
is_active: boolean;
created_at: Date;
updated_at: Date;
}
export type CustomerCreationAttributes = Optional<CustomerAttributes, "id">;
export class Customer
extends Model<CustomerAttributes, CustomerCreationAttributes>
implements CustomerAttributes
{
id!: string;
name!: string;
email?: string;
is_active!: boolean;
created_at!: Date;
updated_at!: Date;
static initModel(sequelize: Sequelize.Sequelize): typeof Customer {
return Customer.init(
{
id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true,
},
name: {
type: DataTypes.STRING(255),
allowNull: false,
},
email: {
type: DataTypes.STRING(255),
allowNull: true,
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
},
},
{
sequelize,
tableName: "Customers",
timestamps: true,
underscored: true,
}
);
}
}
This is a basic model representing a Customer
entity with fields like id
, name
, email
, is_active
, created_at
, and updated_at
.
3. Using the Repository in Your Code
Once your repositories are set up, you can use them in your code to handle database interactions. Here’s an example of how you would use the CustomerRepository
to find a specific customer based on their ID and organisation_id
.
const customerRepository = new CustomerRepository(Customer);
const customer = await customerRepository.findOne({
where: { id: customer_id, organisation_id },
});
With this setup, your data access logic is now centralized, making it easier to manage and scale as your application grows.
4. Benefits of the Generic Repository Pattern
The benefits of this approach are substantial:
-
Eliminates Redundancy : You avoid rewriting common CRUD logic for each model, reducing errors and saving time.
-
Code Reusability : Since the repository is generic, it can be reused across multiple models, making your codebase more maintainable.
-
Type Safety : TypeScript’s generics ensure that your repository methods are type-safe, which helps catch errors during compile time rather than at runtime.
-
Maintainability : As your project scales, it becomes easier to manage, test, and refactor, especially when changes to data access logic are required.
Conclusion
The generic repository pattern is an excellent design choice for TypeScript-based backend projects, allowing for cleaner, reusable, and more maintainable code. By leveraging TypeScript’s static typing system and generics, you ensure that your application is not only easier to scale but also safer from potential runtime errors.