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 - limited type inferenceconst result = await em.getConnection().getKnex()('book') .select('title', 'author_id') .where('published', true)
// result is any[] - no type safetyI 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:
- MikroORM builds queries itself (no external query builder)
- Kysely runs as the query runner
- You get a fully typed Kysely instance for raw queries
// Get the typed Kysely instance from EntityManagerconst kysely = em.getKysely()
// Full type inference on raw queriesconst result = await kysely .selectFrom('book') .where('title', '=', 'TypeScript Guide') .selectAll() .execute()
// result is typed as Book[] automaticallyThis 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 weren't trackedconst books = await em.find(Book, { 'b.title': { $like: '%TypeScript%' }}, { alias: 'b', populate: ['author']})
// 'b' alias wasn't validated - typos silently failedIn MikroORM 7:
// MikroORM 7 - aliases tracked through genericsconst 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 matchThe 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 query with window functionsconst 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:
// 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 - this is Knex syntaxawait em.getKysely()('book').where('title', '=', 'Guide')
// CORRECT - Kysely syntaxawait em.getKysely() .selectFrom('book') .where('title', '=', 'Guide') .selectAll() .execute()Mistake 2: Forgetting to Execute
Kysely builds queries lazily. I forgot .execute() more than once:
// WRONG - returns a query builder, not resultsconst query = em.getKysely() .selectFrom('book') .selectAll()
// CORRECT - actually executesconst 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:
// DON'T - mixing paradigmsconst rawBooks = await em.getKysely() .selectFrom('book') .selectAll() .execute()
// Raw results aren't managed entitiesawait em.persistAndFlush(rawBooks[0]) // Won't work!
// DO - use the right tool for each jobconst book = em.create(Book, { title: 'New Book' })await em.persistAndFlush(book) // Entity management via MikroORM
// Use Kysely for read-only analyticsconst 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
npm install @mikro-orm/core@7 @mikro-orm/postgresql@72. Update Configuration
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:
// 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:
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