Hi Dev community ๐๐พ!
I just decided that I want to write a post so here I AM. I didn't know what to write about and I landed on the idea of Sequelize + TypeScript my experience working with Sequelize and adding TypeScript to it.
Table of Content
- A Little bit of Background
- Setup Sequelize
- Setup Sequelize Models
- Adding Model Association
- Conclusion
A Little bit of Background
So the idea came from a project that I was managing and the client decided that they wanted the project handed in yesterday (as usual), so the company put together a small group of Jr. Engineers plus me as the Senior/Lead Engineer so I would be in charge of choosing the stack so I took the decision of using:
- Express/NodeJS (Backend)
- PostgreSQL (Database)
- Plain React (Frontend)
- Sequelize (ORM)
Since any of the Jr. Engineers had any experience developing on TypeScript nor working on a microservices-based architecture I decided that it would be good for us to use plained JS and monolithic architecture since that what they knew at the moment and I didn't want to slow down the project.
Now I hear you asking what does this have to do with Sequelize + TypeScript? Well, everything. The company decided to use the project as a base ground for another project and it's here when we noticed all the limitations and problems that we have with the current setup. Just to name a few:
- A lot of duplicated code both in the Backend and the Frontend
- Slow dev environment
- Weird bugs that cause the project to crash
- It was hard to understand what is doing what
- Database Models were messy
- And more...
So here I'm refactoring the project using TypeScript and setting up a Microservice-based architecture with Event-Based Communication (as it should) while my team is working on bug fixes and waiting for me to be done with the refactoring.
Well enough talking let's dive in and set up Sequelize to use TypeScript.
Setup Sequelize
I'll be using the sequelize-cli tool which generates the Sequelize folder structure and configuration based on the .sequelizerc
file which should look like this
const path = require('path');
module.exports = {
config: path.resolve('.', 'config.js'),
'models-path': path.resolve('./db/models'),
'seeders-path': path.resolve('./db/seeders'),
'migrations-path': path.resolve('./db/migrations')
};
You can now run npx sequelize-cli init
and you will have the following project structure
- config.js
- db/
- migrations/
- models/
- index.js
- seeders/
Your db/models/index.js
file will look like this
'use strict';
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require('../../app/config')[env];
const db = {};
let sequelize;
if (config.url) {
sequelize = new Sequelize(config.url, config);
} else {
// another sequelize configuration
}
fs.readdirSync(__dirname)
.filter(file => {
return (
file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js'
);
})
.forEach(file => {
const model = sequelize['import'](path.join(__dirname, file));
db[model.name] = model;
});
Object.keys(db).forEach(modelName => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
module.exports = db;
Yeah, a bunch of code, but this essentially eases the use of models and reduces the amount of requires/imports
that you'd do in your models in the future.
Pretty cool stuff, but I want to use TypeScript I hear you say. Ok so let's change our first file to TypeScript. Rename the index.js
file to index.ts
and let's change some things in it
import { Sequelize } from 'sequelize';
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../../config.js')[env];
const sequelize = config.url
? new Sequelize(config.url, config)
: new Sequelize(config.database, config.username, config.password, config);
export { Sequelize, sequelize };
Yup, that's it!
Ok, let me explain why we removed a bunch of code from the file, and the reason is due to TypeScript being TypeScript. I just didn't want to type the db
Object that is being generated based on the files inside the models/
directory. So we removed all of that and just exported Sequelize
(Class) and sequelize
(instance). The sequelize
instance has a reference to the database connection so when we create our models we use this instance so we can communicate with the database through the models.
The first file is done and a bunch more to go.
Setup Sequelize Models
If we were to use JS a Sequelize model would look like this.
NOTE: I'll be declaring two example models: Author, and Book.
Let's first see how to add a Sequelize Model using JS. The Author Model would look like the following
// /models/author.js
module.exports = (sequelize, DataTypes) => {
const Author = sequelize.define(
'Author',
{
id: {
allowNull: false,
autoIncrement: false,
primaryKey: true,
type: DataTypes.UUID,
unique: true,
},
firstName: {
allowNull: true,
type: DataTypes.TEXT,
},
lastName: {
allowNull: false,
type: DataTypes.TEXT,
},
email: {
allowNull: true,
type: DataTypes.TEXT,
},
},
{}
);
return Author;
};
So let's give the TypeScript treat to the Author model as well. Change the name to author.ts
and let's define some interfaces
import { Model, Optional } from 'sequelize';
interface AuthorAttributes {
id: string;
firstName: string;
lastName: string;
email: string;
};
/*
We have to declare the AuthorCreationAttributes to
tell Sequelize and TypeScript that the property id,
in this case, is optional to be passed at creation time
*/
interface AuthorCreationAttributes
extends Optional<AuthorAttributes, 'id'> {}
interface AuthorInstance
extends Model<AuthorAttributes, AuthorCreationAttributes>,
AuthorAttributes {
createdAt?: Date;
updatedAt?: Date;
}
We need these interfaces to tell TypeScript which properties an author instance has.
Now let's define the Author Model in the TS way
// model/author.ts
import { sequelize } from '.';
// ... instances code
const Author = sequelize.define<AuthorInstance>(
'Author',
{
id: {
allowNull: false,
autoIncrement: false,
primaryKey: true,
type: DataTypes.UUID,
unique: true,
},
firstName: {
allowNull: true,
type: DataTypes.TEXT,
},
lastName: {
allowNull: false,
type: DataTypes.TEXT,
},
email: {
allowNull: true,
type: DataTypes.TEXT,
},
}
);
Now you should be wondering, why do I have to go through all of that if I'll end up doing the same? Well, if you made a typo on any property of the model define object, TypeScript will yell at you and tell you that the property X
is missing, same if you try to add a property that is not defined on the AuthorAttributes
interface TypeScript will tell you that property Y
is not defined on type AuthorAttributes
Now that we know how to declare a Sequelize model using TypeScript let's define the book.ts
model.
// /models/book.ts
import { Model, Optional } from 'sequelize';
import { sequelize } from '.';
interface BookAttributes {
id: string;
title: string;
numberOfPages: number;
authorId: string;
}
interface BookCreationAttributes
extends Optional<BookAttributes, 'id'> {}
interface BookInstance
extends Model<BookAttributes, BookCreationAttributes>,
BookAttributes {
createdAt?: Date;
updatedAt?: Date;
}
const Book = sequelize.define<BookInstance>(
'Book',
{
id: {
allowNull: false,
autoIncrement: false,
primaryKey: true,
type: DataTypes.UUID,
unique: true,
},
title: {
allowNull: true,
type: DataTypes.TEXT,
},
numberOfPages: {
allowNull: false,
type: DataTypes.INTEGER,
},
authorId: {
allowNull: true,
type: DataTypes.UUID,
},
}
);
export default Book;
That's it! That's how you define a Sequelize Model using TypeScript.
Adding Model Association
If you want to add associations to the model let's say An Author HAS MANY Books
then in JS you'd do something like this
// model/author.js
Author.associate = models => {
Author.hasMany(models.Book, {
foreignKey: 'authorId',
});
};
// model/book.js
Book.associate = models => {
Book.belongsTo(models.Author, {
foreignKey: 'id'
});
};
So do you remember when I said that the code generated when we ran npx sequelize-cli init
will help us when trying to do requires/imports
? That time has come!
Sequelize will try to run the associate function if defined and will add the defined associations to the Models.
But we are here for TypeScript not for JS, so how do we do this in our defined models in TypeScript? Let's see how.
// models/author.ts
import Book from './book';
// ... Code Defining the Author Model
Author.hasMany(Book, {
/*
You can omit the sourceKey property
since by default sequelize will use the primary key defined
in the model - But I like to be explicit
*/
sourceKey: 'id',
foreignKey: 'authorId',
as: 'books'
});
// models/book.ts
import Author from './author';
// ... Code Defining the Book Model
Book.belongsTo(Author, {
foreignKey: 'authorId',
as: 'author'
});
Is that it?
I hear you say. Well, if things were that simple in this world I wouldn't be writing this post ๐
.
So why wouldn't this work? Sequelize has a weird way to work and if we try to define this association in their respective files (as we do in JS) Sequelize will throw this error:
throw new Error("${source.name}.${_.lowerFirst(Type.name)} called with something that's not a subclass of Sequelize.Model");
^
"Error: Book.belongsTo called with something that's not a subclass of Sequelize.Model"
At first, I thought I was missing something or that I didn't type the Author Model as a Sequelize Model but after double-checking everything was good, TypeScript was not complaining that I was trying to call the belongsTo
method with something else that's not a Sequelize Model. So I was wondering why is this happening? ๐ค
I was reading the Sequelize documentation and posts from around the web and didn't find anything that would work, except someone (sorry didn't find the link to the StackOverflow thread) that suggested that one should put their association in one Model file. I was skeptical about it since I said to myself: how would this work since I'm exporting the same model that is in my file. However, I gave it a try. I moved the belongsTo
call to the models/author.ts
file:
// model/author.ts
import Book from './book';
// ... a bunch of code here
Book.belongsTo(Author, {
foreignKey: 'authorId',
as: 'author'
});
And Lord and behold my project was running:
The library Server is running on http://localhost:3000
It is weird but it seems that you have to define the belongsTo
association in the same file that you set the hasMany
or the hasOne
association.
Conclusion
In the end, setting up Sequelize with TypeScript wasn't hard at all, and now we know which properties belong to which model.
For me, it was a valuable experience since I thought that translating my JS files to TS would just be done by renaming the file and that would be it since I believed that Sequelize would handle the typing work in the background.
Note: I know that there is a package out there called sequelize-typescript but I didn't feel like adding another dependency to my project, plus this package is not maintained by the Sequelize team. However, I just tried it and it's pretty cool, so give it a try if you don't want a setup like mine.
By the end of the day, I don't feel like having another dependency in my project, and I was able to accomplish the goal of adding TypeScript to Sequlize without a third-party library just with pure TS.
Sorry for the long post, but I hope you find it useful if you are trying to use Sequelize and TypeScript together.๐๐พ
Top comments (13)
Sequelize-typescript package is very helpful, it allows to define model with decorators and transpiles into easily usable objects to pass through graphql, without creating additional boilerplate DTO.
Also, check out Nestjs. It's very flexible, code first, easy to use and
"federationable" typescript based, backend framework.
nestjs.com
Totally agree with you! That's why I added the note at the end.
I'm changing a lot of stuff both in the backend and the frontend. It's something kinda like an extreme makeover for the project. ๐
How would the sequelize-cli db:migrate command work with typescript?
Just dropping by the comments to say that you saved me! Was having the same problem related to the Sequelize subclass and didn't find anything useful in Google until I came across your post.
Really appreciated!
Maybe a bit silly, but I was starting without
sequelize
installed, and needed to runnpm i sequelize
beforenpx sequelize-cli init
Your model files are ending with .ts but
db/models/index.js
is looking for .js files so this probably doesn't work.I think it will be better to keep the db Object that is being generated based on the files inside the models so you can avoid :
Book.belongsTo called with something that's not a subclass of Sequelize.Model error
Nice heads up, i love it
Thank you! This improves DX quite significantly
Awesome post, thank you.
But I wondering how to add or remove column to existed model.
Hey @minhdc998, you should be able to remove the property from your model and then use the sequelize-cli to generate a migration in which you would write a table alter statement to remove it from the database.
There is supposed to be an
alter
property you can pass to the sync method which might attempt to do this for you, but I've had issues with it creating duplicate foreign key constraints, so I haven't used it much.Thanks a lot.