Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Using Sequelize with TypeScript

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.