I have done it one last time, and everything will be ok.

These are the most popular database libraries in the JavaScript ecosystem.

All of them have their own way of defining database schemas.

TypeORM uses decorators and classes:

import {
  Entity,
  Column,
  PrimaryGeneratedColumn
} from 'typeorm'

@Entity()
export class Photo {
  @PrimaryGeneratedColumn()
  id: number

  @Column({
    length: 100,
  })
  name: string

  @Column('text')
  description: string

  @Column()
  filename: string

  @Column('double')
  views: number

  @Column()
  isPublished: boolean
}

In Sequelize, you can use sequelize.define() or create a subclass of Model and init() it. Internally, both approaches are equivalent. I'm not going to reproduce the snippets here, because they win the verbosity prize hands down.

Prisma has its own DSL for defining schemas:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  email     String   @unique
  name      String?
  role      Role     @default(USER)
  posts     Post[]
}

Which is then used to generate the initial SQL schemas and also to perform migrations. Through Prisma's CLI, changing the schema file is enough to generate a raw SQL file with your migration.

In Drizzle, schemas are defined as follows:

import {
  pgTable,
  serial,
  varchar,
  text,
  timestamp,
  integer,
} from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 100 }),
  email: varchar('email', { length: 255 }).notNull().unique(),
  passwordHash: text('password_hash').notNull(),
  role: varchar('role', { length: 20 }).notNull().default('member'),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at').notNull().defaultNow(),
  deletedAt: timestamp('deleted_at'),
});

Taken from the schema.ts file from nextjs/saas-starter.

Drizzle is impressive — there are many things to like about it. For one thing, it takes care of the N+1 problem by ensuring only one query is issued, regardless of its relations. It provides both SQL-like and abstracted querying APIs. It offers a similar experience to Prisma when dealing with migrations, creating SQL files from changes in the schema, also through a CLI. It also provides type-safe syntactic sugar for working with table relations:

import { relations } from 'drizzle-orm'

export const activityLogsRelations = relations(activityLogs, ({ one }) => ({
  team: one(teams, {
    fields: [activityLogs.teamId],
    references: [teams.id],
  }),
  user: one(users, {
    fields: [activityLogs.userId],
    references: [users.id],
  }),
}));

export const teamsRelations = relations(teams, ({ many }) => ({
  teamMembers: many(teamMembers),
  activityLogs: many(activityLogs),
  invitations: many(invitations),
}));

It seems people really enjoy Kysely, but want the enhanced DX of Drizzle or Prisma. So we have things like drizzle-kysely, prisma-extension-kysely and prisma-kysely. What if we could just use Kysely and still have the same DX?

ORMs are nice, they make code look nice and queries look easy. But in any JavaScript runtime, that comes at a cost. Objects are expensive. APIs that use the least amount of objects will cause the least amount of overhead. ORMs tend to require a lot of objects and function calls in comparison to query builders.

The problem with delegating query complexity to an ORM is that, more often than not, you have to pay the price. I've seen dozens of times, let's just rewrite this one query in raw SQL, and off you go about making sense of what the ORM has long kept you away from. There's also the infamous N+1 problem which still surprises developers to this date.

After evaluating all available options, and considering the opinion of people I respect, I'm sticking with Kysely. Its type-safety is rock solid, the API is clean, the project is very well maintained and it's the most lightweight library:

  • node_modules/kysely: 6.3mb
  • node_modules/prisma: 18mb
  • node_modules/drizzle-orm: 16mb

But Kysely's migration API differs from the API used for defining your tables. You're supposed to have your Kysely table types, and also employ a completely different set of methods for creating and modifying tables.

So I had idea of the extending Kysely table types with something I'm calling annotation types. These are just wrapper types, returning the original type, but they serve as hints for processing the interface source by other tools, such as a CLI that generates SQL schemas. And with that, kysely-tables was born:

A proof-of-concept exploring this idea, showing how to use the same table interfaces for defining tables, automatically creating migrations, and querying.

It's not quite production-ready yet, but it's in an extremely good point to grow.

If you, like me, think the currently available options are a bit too much, or as I like to call them, architectural behemoths, and appreciate the simplicity of this new library, join me in testing it and hopefulling evolving it to perfection:

https://github.com/galvez/kysely-tables