← Back to DevBytes

Sequelize TypeScript: Strongly Typed Applications

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:

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles