Sequelize is an ORM for Node.js written in JavaScript, not TypeScript. Despite that good quality typings are available, it is not straightforward how to get up to speed with Sequelize and TypeScript. I would like to go through crucial elements and show how to maximize safety coming from static typing when using Sequelize. Let's start with setting things up.
Following code examples are available also on GitHub.
Setup
Sequalize is very flexible when it comes to how you decide to structure your project. Basic directory structure can be configured with .sequelizerc. At the same time, Sqeuelize makes a few assumptions and to make a full advantage of certain features you should comply. One of such assumptions is the directory to which you generate models (using sequelize-cli
) is the same as the directory from where you access those models. It might be true in case of a JavaScript project but it is not uncommon to build TypeScript project into a separate directory like build or dist.
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"strict": true,
"moduleResolution": "node"
},
"include": [
"src/**/*.ts"
]
}
To compile .ts files to .js which are in the same directory it is enough to just include them specifying a correct path. Remove outDir from your tsconfig.json.
// .sequelizerc
const path = require("path");
module.exports = {
config: path.resolve("src", "db", "config.json"), // can be also a .js file
"models-path": path.resolve("src", "db", "models"),
"seeders-path": path.resolve("src", "db", "seeders"),
"migrations-path": path.resolve("src", "db", "migrations")
};
Pointing Sequalize to the correct directory will make it possible to use command line tools to generate a migration or populate a seed. The config can also be a .js
file which makes possible to e.g. read environmental variables, extend other configs etc.
Models
Models are where things get interesing. There are three entities you should understand before moving forward: Attributes, Instance, and a Model.
interface PostAttributes {} // fields of a single database row
interface PostInstance {} // a single database row
interface PostModel {} // a table in the database
Attributes interface is a simple definition of all the attributes you specify when creating a new object. It is better to think about it this way than just fields of a single database row. The reason is: you do not want auto-generated id or updatedAt
field to be required when saving a new record.
// src/db/models/product.ts
interface ProductAttributes {
id?: string; // id is an auto-generated UUID
name: string;
price: string; // DOUBLE is a string to preserve floating point precision
archived?: boolean; // is false by default
createdAt?: string;
updatedAt?: string;
}
Instance represents an actual row you fetch from the database. It should contain all attributes and a few additional methods such as getValue or save. There is nothing more to defining an instance type than combining Sequalize.Instance<TAttribues>
and TAttributes
.
// src/db/models/product.ts
type ProductInstance = Sequelize.Instance<ProductAttributes> & ProductAttributes;
type ProductModel = Sequelize.Model<ProductInstance, ProductAttributes>;
Model is created using sequalize.define
. I recommend not instantiating model directly but wrapping it into a factory function and exporting that function.
// src/types.d.ts
import { DataTypeAbstract, DefineAttributeColumnOptions } from "sequelize";
declare global {
type SequelizeAttributes<T extends { [key: string]: any }> = {
[P in keyof T]: string | DataTypeAbstract | DefineAttributeColumnOptions;
};
}
// src/db/models/product.ts
export function initProduct(sequalize: Sequelize.Sequelize): ProductModel {
const attributes: SequelizeAttributes<ProductAttributes> = {
id: { type: Sequelize.UUID, primaryKey: true, defaultValue: Sequelize.UUIDV4 },
name: { type: Sequelize.STRING, allowNull: false },
price: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
archived: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
};
const Product = sequalize.define<ProductInstance, ProductAttributes>("Product", attributes);
return Product;
};
A helper type, SequelizeAttributes, will not let you forget about specifying or leaving not implemented type in your attributes interface. Out of the box sequalize.define
does not give you this guarantee.
Associations
In this setup, that consists of the factory function, associations in Sequalize can be implemented by defining the associate
method on the model. Remember to reflect the associations also in the attributes interface.
// src/db/models/product.ts
import { ManufacturerModel } from "./manufacturer";
interface ProductAttributes {
// ...
manufacturer?: ManufacturerModel
}
export function initProduct(sequalize: Sequelize.Sequelize): ProductModel {
// ...
// @ts-ignore
Product.associate = ({ Manufacturer }: { Manufacturer: ManufacturerModel }) => {
Product.belongsTo(Manufacturer);
};
return Product;
};
Model loader
Model loader is a module exporting an object with all models available. The loader you get after executing sequelize init
is smart in the sense that it dynamically loads all models from the models' directory building db
in the loop. It makes types inference impossible so I am used to ditching it and replacing with explicit declaration of db
object.
// src/db/models/index.ts
import Sequelize from "sequelize";
import { initProduct } from "./product";
import { initManufacturer } from "./manufacturer";
const env = process.env.NODE_ENV || "development";
const config = require("/../config.json")[env];
const url = config.url || process.env.DATABSE_CONNECTION_URI;
const sequelize = new Sequelize(url, config);
const db = {
sequelize,
Sequelize,
Manufacturer: initManufacturer(sequelize),
Product: initProduct(sequelize),
};
Object.values(db).forEach((model: any) => {
if (model.associate) {
model.associate(db);
}
});
export default db;
Wrap up
You may also go a step further and write seeders and migrations in TypeScript. I don't do it as the only consumer of those files is sequelize-cli
. I edit generated JavaScript files but with this config, it is not a problem if you want to use TypeScript. Just change the extension of generated files to .ts
.
As a further reading on the topic, I recommend reading comments from types definition. They often come in handy during development and are well structured.
Photo by Kevin Ku on Unsplash.