Introduction to Sequelize with TypeScript
Sequelize is one of the most popular Object-Relational Mappers (ORMs) for Node.js, providing a powerful abstraction layer over SQL databases. When paired with TypeScript, it transforms into a robust tool for building strongly typed applications that catch errors at compile time rather than runtime. This tutorial walks you through everything you need to know to build production-ready Sequelize applications with TypeScript.
What Is Sequelize TypeScript?
Sequelize TypeScript refers to the practice of using the Sequelize ORM within a TypeScript codebase. Since version 5, Sequelize has shipped with first-class TypeScript support, including type definitions and decorators. This allows developers to define models, associations, and queries with full type safety, eliminating the common pitfalls of untyped JavaScript database interactions.
There are two primary approaches to using Sequelize with TypeScript:
- Traditional model definition — Using
sequelize.define()with explicit typing through interfaces and generics. - Decorator-based models — Using
@sequelize/coredecorators (the modern approach recommended for new projects).
Why Strong Typing Matters in Database Applications
Database operations are inherently risky. A mistyped column name, a wrong data type, or an unexpected null value can cause runtime crashes, data corruption, or silent bugs. TypeScript mitigates these risks by:
- Catching schema mismatches during development.
- Providing autocompletion for model attributes and associations.
- Enforcing return types on queries so downstream code knows exactly what shape the data has.
- Reducing the need for manual runtime validation.
- Making refactoring safer and more predictable.
In a plain JavaScript Sequelize app, a typo in a where clause silently returns no results. In TypeScript, the compiler flags it immediately.
Setting Up the Project
Let's start by creating a new project and installing the necessary dependencies.
mkdir sequelize-ts-app
cd sequelize-ts-app
npm init -y
npm install sequelize sequelize-typescript pg pg-hstore reflect-metadata
npm install -D typescript ts-node @types/node @types/pg
npx tsc --init
Update your tsconfig.json to enable decorator metadata, which Sequelize relies on for type inference:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
Configuring the Sequelize Instance
Create a database configuration file that initializes the Sequelize instance. With sequelize-typescript, you pass model classes directly to the constructor.
// src/database.ts
import { Sequelize } from 'sequelize-typescript';
import { User } from './models/User';
import { Post } from './models/Post';
export const sequelize = new Sequelize({
database: process.env.DB_NAME || 'myapp',
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASS || 'password',
host: process.env.DB_HOST || 'localhost',
dialect: 'postgres',
models: [User, Post],
logging: false,
});
export async function connectDatabase(): Promise<void> {
try {
await sequelize.authenticate();
console.log('Database connection established successfully.');
await sequelize.sync({ alter: true });
console.log('All models synchronized.');
} catch (error) {
console.error('Unable to connect to the database:', error);
process.exit(1);
}
}
Defining Models with Decorators
Decorator-based models are the cleanest way to define strongly typed entities. Each class property maps to a database column, and TypeScript types flow naturally through your application.
Creating the User Model
// src/models/User.ts
import { Table, Column, Model, HasMany, CreatedAt, UpdatedAt } from 'sequelize-typescript';
import { Post } from './Post';
@Table({
tableName: 'users',
timestamps: true,
})
export class User extends Model {
@Column({ primaryKey: true, autoIncrement: true })
declare id: number;
@Column({ allowNull: false, unique: true })
declare email: string;
@Column({ allowNull: false })
declare passwordHash: string;
@Column({ allowNull: false })
declare firstName: string;
@Column({ allowNull: false })
declare lastName: string;
@Column({ defaultValue: true })
declare isActive: boolean;
@CreatedAt
declare createdAt: Date;
@UpdatedAt
declare updatedAt: Date;
@HasMany(() => Post)
declare posts: Post[];
}
Notice the use of the declare keyword. This tells TypeScript that these properties are provided by Sequelize at runtime through the Model base class, so the compiler does not emit them in the compiled output. This prevents initialization errors in strict mode.
Creating the Post Model
// src/models/Post.ts
import { Table, Column, Model, ForeignKey, BelongsTo, CreatedAt, UpdatedAt } from 'sequelize-typescript';
import { User } from './User';
@Table({
tableName: 'posts',
timestamps: true,
})
export class Post extends Model {
@Column({ primaryKey: true, autoIncrement: true })
declare id: number;
@Column({ allowNull: false })
declare title: string;
@Column({ allowNull: false, type: 'TEXT' })
declare content: string;
@Column({ defaultValue: 0 })
declare viewCount: number;
@ForeignKey(() => User)
@Column({ allowNull: false })
declare userId: number;
@BelongsTo(() => User)
declare user: User;
@CreatedAt
declare createdAt: Date;
@UpdatedAt
declare updatedAt: Date;
}
Performing CRUD Operations
With models defined, you can now perform database operations with full type safety. Every query method returns properly typed results.
Creating Records
// src/services/userService.ts
import { User } from '../models/User';
export async function createUser(data: {
email: string;
passwordHash: string;
firstName: string;
lastName: string;
}): Promise<User> {
const user = await User.create(data);
return user;
}
export async function createMultipleUsers(): Promise<User[]> {
return await User.bulkCreate([
{ email: 'alice@example.com', passwordHash: 'hash1', firstName: 'Alice', lastName: 'Smith' },
{ email: 'bob@example.com', passwordHash: 'hash2', firstName: 'Bob', lastName: 'Jones' },
]);
}
Reading Records
import { User } from '../models/User';
import { Op } from 'sequelize';
export async function findUserById(id: number): Promise<User | null> {
return await User.findByPk(id);
}
export async function findActiveUsers(): Promise<User[]> {
return await User.findAll({
where: { isActive: true },
order: [['createdAt', 'DESC']],
});
}
export async function searchUsersByName(query: string): Promise<User[]> {
return await User.findAll({
where: {
[Op.or]: [
{ firstName: { [Op.iLike]: `%${query}%` } },
{ lastName: { [Op.iLike]: `%${query}%` } },
],
},
});
}
Updating Records
import { User } from '../models/User';
export async function updateUserEmail(id: number, newEmail: string): Promise<[number, User[]]> {
return await User.update(
{ email: newEmail },
{ where: { id }, returning: true }
);
}
export async function deactivateUser(id: number): Promise<void> {
const user = await User.findByPk(id);
if (!user) {
throw new Error('User not found');
}
user.isActive = false;
await user.save();
}
Deleting Records
import { User } from '../models/User';
export async function deleteUser(id: number): Promise<number> {
return await User.destroy({ where: { id } });
}
Working with Associations
One of the most powerful features of strongly typed Sequelize is the ability to eagerly load associations with proper typing. The Includeable options ensure that only valid associations can be specified.
// src/services/postService.ts
import { Post } from '../models/Post';
import { User } from '../models/User';
export async function getPostWithAuthor(postId: number): Promise<Post | null> {
return await Post.findByPk(postId, {
include: [User],
});
}
export async function getUserWithPosts(userId: number): Promise<User | null> {
return await User.findByPk(userId, {
include: [{ model: Post, as: 'posts' }],
});
}
export async function getRecentPostsByActiveAuthors(): Promise<Post[]> {
return await Post.findAll({
include: [
{
model: User,
where: { isActive: true },
},
],
order: [['createdAt', 'DESC']],
limit: 10,
});
}
When you access post.user or user.posts, TypeScript knows the exact type, so you get autocompletion and compile-time validation.
Transactions
Transactions ensure data integrity when multiple operations must succeed together. Sequelize transactions work seamlessly with TypeScript's async/await.
// src/services/transferService.ts
import { sequelize } from '../database';
import { User } from '../models/User';
export async function transferCredits(
fromId: number,
toId: number,
amount: number
): Promise<void> {
const transaction = await sequelize.transaction();
try {
const sender = await User.findByPk(fromId, { transaction });
const receiver = await User.findByPk(toId, { transaction });
if (!sender || !receiver) {
throw new Error('User not found');
}
// Perform your business logic here
// For example, deduct and add credits
await transaction.commit();
console.log('Transfer completed successfully.');
} catch (error) {
await transaction.rollback();
console.error('Transfer failed, rolled back:', error);
throw error;
}
}
Best Practices
Use the declare Keyword
Always use declare for model fields that are managed by Sequelize. This prevents TypeScript from emitting initializer code that conflicts with Sequelize's internal property management.
Define Input and Output Types Explicitly
Create dedicated types for creation payloads and update payloads. This separates what the database accepts from what your API exposes.
// src/types/userTypes.ts
export interface UserCreationAttributes {
email: string;
passwordHash: string;
firstName: string;
lastName: string;
}
export interface UserUpdateAttributes {
email?: string;
firstName?: string;
lastName?: string;
isActive?: boolean;
}
export interface UserPublicDTO {
id: number;
email: string;
firstName: string;
lastName: string;
isActive: boolean;
createdAt: Date;
}
export function toUserDTO(user: User): UserPublicDTO {
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
isActive: user.isActive,
createdAt: user.createdAt,
};
}
Validate at the Model Level
Use Sequelize's built-in validators alongside TypeScript types. Types protect you at compile time, but validators protect you at runtime when data comes from external sources.
@Column({
allowNull: false,
validate: {
isEmail: true,
notEmpty: true,
},
})
declare email: string;
Avoid any in Queries
Never use any to bypass type checking in where clauses or include options. If TypeScript complains, it is usually pointing out a real issue with your query.
Use Migrations Instead of sync() in Production
While sequelize.sync() is convenient during development, use a migration system like sequelize-cli or umzug for production deployments. Migrations give you version control over your schema and allow safe rollbacks.
npx sequelize-cli init
npx sequelize-cli model:generate --name User --attributes email:string,passwordHash:string,firstName:string,lastName:string
npx sequelize-cli migration:run
Enable Strict Mode
Keep "strict": true in your tsconfig.json. This enforces null checks and prevents implicit any, which is essential for catching database-related bugs early.
Putting It All Together
Here is a simple entry point that ties everything together:
// src/index.ts
import 'reflect-metadata';
import { connectDatabase } from './database';
import { createUser } from './services/userService';
import { getUserWithPosts } from './services/postService';
async function main(): Promise<void> {
await connectDatabase();
const newUser = await createUser({
email: 'charlie@example.com',
passwordHash: 'securehash',
firstName: 'Charlie',
lastName: 'Brown',
});
console.log('Created user:', newUser.id);
const userWithPosts = await getUserWithPosts(newUser.id);
console.log('User posts:', userWithPosts?.posts ?? []);
}
main().catch(console.error);
Conclusion
Combining Sequelize with TypeScript gives you the best of both worlds: the productivity of a mature ORM and the safety of static typing. By defining models with decorators, using the declare keyword correctly, separating creation and update types, and leveraging typed associations, you can build database applications that are significantly more maintainable and less error-prone. The upfront investment in typing pays dividends as your application grows, making refactoring safer, onboarding faster, and runtime bugs rarer. Start with strict mode enabled, prefer migrations over schema sync in production, and let the TypeScript compiler guide you toward a more reliable codebase.