In Search of the Perfect TypeScript ORM!

Roger Padilla
6 min readJan 27, 2023

--

What is an ORM?

An ORM provides a simpler way to interact with databases in an app, it allows developers to work with data using objects.

What is a Perfect ORM for TypeScript?

Below are the ideal features a TypeScript ORM should have, why such features are the most important ones, and how the existing ORMs fall short in most of these areas.

The top 5 features a perfect TypeScript ORM should have are:

1. Serializable queries:

The ability to transport queries between the layers of a system is great for flexibility and simplicity. For example, the front/client could transmit the queries to the back/server using the same (ORM) syntax and avoid the need for an additional syntax layer like what happens with GraphQL in the flow GraphQL => ORM => Database. The flow could instead be simplified as ORM => Database; with the following advantages:

  • Keep away from the need for a pseudo-language to define the queries (context-switching). Instead, use standard JSON as the syntax for the queries so they are entirely declarative and serializable.
  • Avoid additional servers and steps in the build process of the app.
  • Native support of the editors/IDEs for the queries without any custom plugins/extensions.

2. Native TypeScript:

Squeeze the TypeScript’s power with JSON, classes, and decorators.

  • Type-safe queries and models that are natively validated by the same language that you use to write your app.
  • Context-aware queries allow auto-completion of the appropriate operators and fields according to the different parts of a query.
  • Entity definition with standard classes and decorators to avoid the need for proprietary DSLs, extra steps in the build process, and custom extensions for the editors.

3. Multi-level operators:

Operations such as filter, sort, limit, project, and others work on any level of the queries (including relations and their fields).

4. Consistent API across Databases:

Write the queries for any database in a consistent way and then transparently optimize these queries for the configured database dialect.

5. Universal Syntax (language agnostic):

The ability to write queries in any language opens the door to many possibilities, even porting the ORM to other languages. For example, JSON is a first-class citizen format on almost any modern language out there, including Python, Rust, and others (besides JavaScript).

Why do the current top 3 TypeScript ORMs fall short in these features?

What does TypeORM lack to be a perfect ORM?

1. Lack of 100% serializable queries:

Notice how the LessThan operator is a function that has to be imported and called to create a query to negate a condition, and how that precludes any capability to serialize the query.

import { LessThan } from 'typeorm';

const loadedPosts = await dataSource.getRepository(Post).findBy({
likes: LessThan(10)
});

2. Lack of Native TypeScript:

The query dissolves into strings because relations and where can accept any string, thus any invalid string can be put there.

const posts = await connection.manager.find(Post, {
select: ['id'],
relations: ['< anything can go here >']
});

3. Lack of consistent API across databases:

In the section that TypeORM has for MongoDB, there is a self-explanatory warning about this.

4. Lack of Universal Syntax (language agnostic):

It relies on closures to support advanced queries (and not every language out there supports closures).

What does Prisma lack to be a perfect ORM?

1. Lack of Native TypeScript:

  • Context-switching between the custom DSL and TypeScript makes this process obtrusive.
  • Extra steps in the build process to generate the corresponding files from the custom DSL.
  • It is required to install a custom extension for VsCode to get (basic) autocompletion from the editor for the DSL, which is far from being as good and reliable as with TypeScript.
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[]
}

2. Lack of consistent API across databases:

Prisma exposes low-level details about MongoDB that could be encapsulated by the ORM.

model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
// Other fields
}

3. Lack of Universal Syntax (language agnostic):

It relies on its own proprietary DSL to define the models.

What does Mikro-ORM lack to be a perfect ORM?

1. Lack of 100% serializable queries:

Notice how that query uses two separate methods, one for the update and another for the where, this disallows the serialization of its queries.

const qb = orm.em.createQueryBuilder(Author);

qb.update({ name: 'test 123', type: PublisherType.GLOBAL })
.where({ id: 123, type: PublisherType.LOCAL });

2. Lack of Native TypeScript:

Dissolves into strings, any string can go inside the fields array, so the query lost the possibility of being type-safe.

const author = await em.findOne(Author,
{},
{
fields: ['name', 'books.title', 'books.author', 'books.price']
}
);

3. Lack of Multi-level operators:

What if need to filter the records of a relation or sort them? this seems unachievable in a type-safe way from what can be seen in the Mikro-ORM docs.

4. Lack of consistent API across databases:

import { EntityManager } from '@mikro-orm/mongodb';
const em = orm.em as EntityManager;
const qb = em.aggregate(...);

Mikro-ORM exposes low-level details about MongoDB that could be encapsulated by the ORM.

All that is why I have created a new ORM, nukak!

So, why is nukak the closest option for a perfect ORM? In short, because it was designed from the beginning with all the above foundations, let’s see how.

1. 100% serializable queries:

Even the insert and update operations have a fully serializable API.

const lastUsers = await querier.findMany(User,
{
$project: ['id', 'name', 'email'],
$sort: { createdAt: -1 },
$limit: 20
}
);

2. Native TypeScript with truly type-safe queries:

Every operator and field is validated according to the context. For example, the possible values for the $project operator will automatically depend on the level.

const lastUsersWithProfiles = await querier.findMany(User,
{
$project: {
id: true,
name: true,
profile: {
$project: ['id', 'picture'],
$required: true
}
},
$sort: { createdAt: -1 },
$limit: 20
}
);

3. Multi-level operators:

The operators work on any level. For example, the $sort operator can be applied to the relations and their fields in a type-safe and context-aware way.

const items = await querier.findMany(Item,
{
$project: {
id: true,
name: true,
measureUnit: {
$project: ['id', 'name'],
$filter: { name: { $ne: 'unidad' } },
$required: true
},
tax: ['id', 'name'],
},
$filter: {
salePrice: { $gte: 1000 }, name: { $istartsWith: 'A' }
},
$sort: {
tax: { name: 1 }, measureUnit: { name: 1 }, createdAt: -1
},
$limit: 100,
}
);

3. Consistent API across databases:

Its API is unified across the different databases, it does the magic under the hood so the same entities and queries could transparently work on any of the supported databases. For example, this makes it easier to switch from a Document to a Relational database (or vice-versa).

4. Universal Syntax across languages:

Its syntax is 100% standard JSON, opening the door for many possibilities with other languages, like inter-operation or even creating versions of nukak in other languages such as Python or Rust with ease.

See more on nukak.org

Some of what is missed?

  • Articles about it (this is the first one!).
  • More features (e.g. Migrations and Hooks) and examples.
  • Additional collaborators ;)
  • The newest ORM and the most immature from the list!
  • GitHub Starts! Please give it a start if you’d like! ;)

--

--