Skip to content

MikroORM 7 Kysely Integration: Type-Safe Raw SQL Queries

I was working on a complex analytics dashboard. The ORM handled 90% of my queries fine, but the remaining 10% needed raw SQL — complex aggregations, window functions, CTEs. I’d been using MikroORM 6 with Knex for raw queries, but Knex’s TypeScript support felt incomplete. Type safety worked for simple cases, but complex queries often degraded to any.

Then MikroORM 7 shipped with Kysely integration. Here’s what changed and why it matters.

The Problem: Type Safety Gaps

In MikroORM 6, raw queries through Knex looked like this:

knex-raw-query.ts
// Knex raw query - limited type inference
const result = await em.getConnection().getKnex()('book')
.select('title', 'author_id')
.where('published', true)
// result is any[] - no type safety

I lost type safety the moment I dropped to raw SQL. Autocomplete broke. Refactoring became risky. I could define return types manually, but that was busywork and prone to drift.

The deeper issue: Knex was designed before TypeScript’s type system matured. It works, but it fights the language rather than embracing it.

The MikroORM 7 Solution

MikroORM 7 replaced Knex entirely. Now:

  1. MikroORM builds queries itself (no external query builder)
  2. Kysely runs as the query runner
  3. You get a fully typed Kysely instance for raw queries
kysely-typed-query.ts
// Get the typed Kysely instance from EntityManager
const kysely = em.getKysely()
// Full type inference on raw queries
const result = await kysely
.selectFrom('book')
.where('title', '=', 'TypeScript Guide')
.selectAll()
.execute()
// result is typed as Book[] automatically

This isn’t just about raw queries. The entire query builder now tracks aliases through generics.

Type-Safe Where Conditions

I tried joining tables with aliases in MikroORM 6:

mikroorm-6-aliases.ts
// MikroORM 6 - aliases weren't tracked
const books = await em.find(Book, {
'b.title': { $like: '%TypeScript%' }
}, {
alias: 'b',
populate: ['author']
})
// 'b' alias wasn't validated - typos silently failed

In MikroORM 7:

mikroorm-7-aliases.ts
// MikroORM 7 - aliases tracked through generics
const books = await em.find(Book, {
'b.title': { $like: '%TypeScript%' }, // 'b' is autocompleted
'b.author.name': 'John Doe' // Nested paths validated
}, {
alias: 'b',
populate: ['author']
})
// Type error if alias doesn't match

The QueryBuilder knows which aliases exist. TypeScript catches typos at compile time.

When I Need Complex SQL

The real power shows when I need to drop down to raw SQL. Last week I needed a query with window functions:

complex-analytics.ts
// Complex query with window functions
const authorStats = await em.getKysely()
.selectFrom('book as b')
.innerJoin('author as a', 'a.id', 'b.author_id')
.select([
'a.name as author_name',
sql<number>`count(*) over (partition by a.id)`.as('book_count'),
sql<number>`row_number() over (order by b.published_at desc)`.as('rank')
])
.groupBy('a.id')
.execute()

The types flow through. I get autocomplete on column names. If I mistype author_id as authorId, TypeScript catches it.

Knex vs Kysely: Syntax Differences

If you’re migrating from Knex, the syntax isn’t identical:

syntax-comparison.ts
// Knex syntax (MikroORM 6)
await knex('book')
.where('title', 'like', '%Guide%')
.select('id', 'title')
// Kysely syntax (MikroORM 7)
await kysely
.selectFrom('book')
.where('title', 'like', '%Guide%')
.select(['id', 'title'])
.execute()

Key differences:

  • Kysely starts with selectFrom() instead of the table name
  • The select() method takes an array in Kysely
  • Kysely requires explicit .execute() to run the query
  • Method order matters more in Kysely

The learning curve is shallow. After a day, Kysely felt more natural than Knex because TypeScript guided me.

Common Mistakes I Made

Mistake 1: Assuming Knex Syntax Works

I tried this first:

wrong-syntax.ts
// WRONG - this is Knex syntax
await em.getKysely()('book').where('title', '=', 'Guide')
// CORRECT - Kysely syntax
await em.getKysely()
.selectFrom('book')
.where('title', '=', 'Guide')
.selectAll()
.execute()

Mistake 2: Forgetting to Execute

Kysely builds queries lazily. I forgot .execute() more than once:

lazy-execution.ts
// WRONG - returns a query builder, not results
const query = em.getKysely()
.selectFrom('book')
.selectAll()
// CORRECT - actually executes
const results = await em.getKysely()
.selectFrom('book')
.selectAll()
.execute()

Mistake 3: Mixing Entity and Raw Queries Incorrectly

I initially tried to combine MikroORM entities with Kysely results:

mixing-approaches.ts
// DON'T - mixing paradigms
const rawBooks = await em.getKysely()
.selectFrom('book')
.selectAll()
.execute()
// Raw results aren't managed entities
await em.persistAndFlush(rawBooks[0]) // Won't work!
// DO - use the right tool for each job
const book = em.create(Book, { title: 'New Book' })
await em.persistAndFlush(book) // Entity management via MikroORM
// Use Kysely for read-only analytics
const stats = await em.getKysely()
.selectFrom('book')
.select(sql<number>`count(*)`.as('total'))
.executeTakeFirst()

When to Use Each Approach

I follow this rule of thumb:

Use MikroORM entities when:

  • Standard CRUD operations
  • Need change tracking
  • Managing relationships
  • Transaction management matters

Use Kysely directly when:

  • Complex aggregations
  • Window functions
  • CTEs (Common Table Expressions)
  • Bulk updates with conditions
  • Performance-critical read paths

Migration Path from MikroORM 6

I migrated a project last month. Here’s what helped:

1. Install MikroORM 7

Terminal window
npm install @mikro-orm/core@7 @mikro-orm/postgresql@7

2. Update Configuration

mikroorm-config.ts
import { defineConfig } from '@mikro-orm/postgresql'
export default defineConfig({
dbName: 'myapp',
// Kysely is the default query runner in v7
// No extra configuration needed
})

3. Replace Knex Raw Queries

Find and replace Knex usage:

migration-example.ts
// Before (MikroORM 6 with Knex)
const raw = await em.getConnection().getKnex()('book')
// After (MikroORM 7 with Kysely)
const raw = await em.getKysely()
.selectFrom('book')
.selectAll()
.execute()

4. Update Type Imports

Kysely types come from the package:

type-imports.ts
import type { Kysely } from 'kysely'
import type { Generated, ColumnType } from 'kysely'

Zero Runtime Dependencies

MikroORM 7’s core has no external runtime dependencies. The query building happens internally, with Kysely only for execution. This means:

  • Smaller bundle size
  • Fewer security vulnerabilities to track
  • Simpler dependency tree

What About ObjectionJS?

I noticed a Reddit comment comparing MikroORM 7 + Kysely to ObjectionJS:

“Seems like the closest thing in TypeScript + Kysely to the beauty that was ObjectionJS”

ObjectionJS was a joy to use — the elegance of Knex with model-layer conveniences. MikroORM 7 captures that same spirit but with first-class TypeScript support. The combination of ORM capabilities and a typed query builder feels similar.

Summary

MikroORM 7’s Kysely integration solved my type safety problem. I get:

  • Full TypeScript inference on raw queries
  • Autocomplete and compile-time validation
  • Seamless switch between ORM and raw SQL
  • Modern query builder designed for TypeScript

The migration from MikroORM 6 took an afternoon. The type safety benefits compound over time. If you’ve been frustrated by Knex’s TypeScript limitations, MikroORM 7 is worth the upgrade.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments