02.07.2026 • 14 min read

Kysely | Modern Type-Safe Query Builder for Node.js

Cover Image

Introduction 🎯

Most TypeScript backend developers face a familiar dilemma: use an ORM and lose fine-grained SQL control, or write raw SQL and lose type safety. Kysely eliminates that trade-off entirely.

Kysely is a type-safe SQL query builder for TypeScript and Node.js. It’s not an ORM — it doesn’t manage entities, relationships, or migrations. Instead, it gives you a fluent, fully type-safe API to construct any SQL query while catching errors at compile time.

// This compiles — types are correct
const result = await db
  .selectFrom('users')
  .innerJoin('posts', 'posts.author_id', 'users.id')
  .select(['users.name', 'posts.title'])
  .where('users.active', '=', true)
  .execute()

// This fails at COMPILE TIME — 'nmae' doesn't exist on users table
const bad = await db
  .selectFrom('users')
  .select(['nmae']) // ❌ TypeScript error: Property 'nmae' does not exist

If you’ve everdebugged a production SQL typo at 3 AM, this article is for you. We’ll build real queries, understand the type system, and see why Kysely is becoming the go-to choice for TypeScript developers who want SQL power without the risk.

Let’s build. 🚀


Part 1: Why Type-Safe Query Builders?

1.1 The ORM Problem

ORMs like TypeORM, Prisma, and Sequelize abstract away SQL. That’s great for simple CRUD, but problematic when you need:

  • Complex JOINs across 5+ tables
  • Window functions, CTEs, or database-specific syntax
  • Full control over query plan and indexing
  • Subqueries that would be awkward to express in ORM methods

ORMs also introduce a leaky abstraction — you still need to understand SQL to debug performance issues, but now you’re debugging through another layer.

1.2 The Raw SQL Problem

Raw SQL gives you full power but introduces:

  • No type safety: A typo in a column name is a runtime error, not a compile-time error
  • String concatenation: SQL injection risks if you’re not careful
  • No IDE support: No autocomplete, no refactoring, no go-to-definition
  • Migration headaches: Schema changes require manual updates everywhere

1.3 The Kysely Solution

Kysely sits in the sweet spot:

FeatureORMRaw SQLKysely
Type safety✅ Entities❌ None✅ Full SQL
SQL power⚠️ Limited✅ Full✅ Full
Performance control⚠️ Abstraction✅ Direct✅ Direct
IDE support✅ Good❌ Minimal✅ Excellent
Learning curve📈 High📉 Low📊 Medium
Runtime overhead📈 High📉 None📉 Minimal

Part 2: Setup & Configuration

2.1 Installation

# PostgreSQL (recommended)
npm install kysely pg @types/pg

# MySQL
npm install kysely mysql2

# SQLite
npm install kysely better-sqlite3

# For TypeScript type generation from existing DB
npm install -D kysely-codegen

2.2 Database Connection

import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'

const db = new Kysely<Database>({
  dialect: new PostgresDialect({
    pool: new Pool({
      host: 'localhost',
      port: 5432,
      database: 'myapp',
      user: 'postgres',
      password: 'password',
    }),
  }),
})

For MySQL:

import { Kysely, MysqlDialect } from 'kysely'
import { createPool } from 'mysql2'

const db = new Kysely<Database>({
  dialect: new MysqlDialect({
    pool: createPool({
      host: 'localhost',
      port: 3306,
      database: 'myapp',
      user: 'root',
      password: 'password',
    }),
  }),
})

2.3 Table Definitions (The Key to Type Safety)

This is where Kysely’s magic happens. You define TypeScript interfaces that mirror your database schema:

import {
  ColumnType,
  Generated,
  JSONColumnType,
  Simplify,
} from 'kysely'

// Generated<T> = auto-incrementing column
// ColumnType< selectType, insertType, updateType > = custom per-operation types

export interface Database {
  users: UsersTable
  posts: PostsTable
  comments: CommentsTable
  tags: TagsTable
  post_tags: PostTagsTable
}

export interface UsersTable {
  id: Generated<number>
  username: string
  email: string
  password_hash: string
  bio: string | null
  avatar_url: string | null
  role: 'admin' | 'user' | 'moderator'
  metadata: JSONColumnType<UserMetadata>
  created_at: ColumnType<Date, string | Date, never> // never = not updatable
  updated_at: ColumnType<Date, string | Date, string | Date>
}

export interface UserMetadata {
  login_count: number
  last_ip: string | null
  preferences: {
    theme: 'light' | 'dark'
    notifications: boolean
  }
}

export interface PostsTable {
  id: Generated<number>
  title: string
  slug: string
  content: string
  excerpt: string | null
  author_id: number
  status: 'draft' | 'published' | 'archived'
  published_at: Date | null
  view_count: ColumnType<number, number, number> // manually managed
  created_at: ColumnType<Date, string | Date, never>
  updated_at: ColumnType<Date, string | Date, string | Date>
}

export interface CommentsTable {
  id: Generated<number>
  content: string
  post_id: number
  author_id: number
  parent_id: number | null // for nested replies
  created_at: ColumnType<Date, string | Date, never>
}

export interface TagsTable {
  id: Generated<number>
  name: string
  slug: string
  created_at: ColumnType<Date, string | Date, never>
}

export interface PostTagsTable {
  post_id: number
  tag_id: number
}

Key concepts:

  • Generated<T>: Column auto-generated by the database (e.g., SERIAL, AUTO_INCREMENT)
  • ColumnType<Select, Insert, Update>: Different types for different operations
  • JSONColumnType<T>: Automatically serializes/deserializes JSON
  • null: Nullable column (e.g., bio: string | null)

Part 3: SELECT Queries

3.1 Basic SELECT

// Select all columns — returns UsersTable[]
const allUsers = await db
  .selectFrom('users')
  .selectAll()
  .execute()

// Select specific columns — returns { name: string; email: string }[]
const userBasic = await db
  .selectFrom('users')
  .select(['username', 'email'])
  .execute()

// Select with alias
const userNames = await db
  .selectFrom('users')
  .select((eb) => [
    'users.username as name',
    'users.email as contact',
  ])
  .execute()

3.2 WHERE Clauses

// Simple equality
const activeUsers = await db
  .selectFrom('users')
  .selectAll()
  .where('role', '=', 'admin')
  .execute()

// Comparison operators
const popularPosts = await db
  .selectFrom('posts')
  .select(['id', 'title', 'view_count'])
  .where('view_count', '>', 1000)
  .where('status', '=', 'published')
  .execute()

// IN clause
const specificUsers = await db
  .selectFrom('users')
  .selectAll()
  .where('id', 'in', [1, 2, 3, 4, 5])
  .execute()

// LIKE / ILIKE (PostgreSQL case-insensitive)
const searchResults = await db
  .selectFrom('posts')
  .select(['id', 'title', 'excerpt'])
  .where('title', 'ilike', '%typescript%')
  .execute()

// IS NULL / IS NOT NULL
const usersWithBio = await db
  .selectFrom('users')
  .select(['id', 'username', 'bio'])
  .where('bio', 'is not', null)
  .execute()

// BETWEEN
const recentPosts = await db
  .selectFrom('posts')
  .selectAll()
  .where('created_at', '>=', new Date('2026-01-01'))
  .where('created_at', '<', new Date('2026-02-01'))
  .execute()

3.3 AND / OR Conditions

// AND (implicit — multiple .where() calls)
const result = await db
  .selectFrom('posts')
  .selectAll()
  .where('status', '=', 'published')
  .where('author_id', '=', 1)
  .execute()
// SQL: WHERE status = 'published' AND author_id = 1

// OR (explicit with eb.or())
const result2 = await db
  .selectFrom('posts')
  .selectAll()
  .where((eb) =>
    eb.or([
      eb('status', '=', 'draft'),
      eb('status', '=', 'published'),
    ])
  )
  .execute()
// SQL: WHERE status = 'draft' OR status = 'published'

// Complex nested conditions
const complex = await db
  .selectFrom('posts')
  .selectAll()
  .where((eb) =>
    eb.and([
      eb('status', '=', 'published'),
      eb.or([
        eb('author_id', '=', 1),
        eb('author_id', '=', 2),
      ]),
    ])
  )
  .execute()
// SQL: WHERE status = 'published' AND (author_id = 1 OR author_id = 2)

3.4 ORDER BY & LIMIT

// Ordered results with pagination
const page = await db
  .selectFrom('posts')
  .select(['id', 'title', 'created_at'])
  .where('status', '=', 'published')
  .orderBy('created_at', 'desc')
  .limit(10)
  .offset(0)
  .execute()

// Multiple sort columns
const sorted = await db
  .selectFrom('posts')
  .selectAll()
  .orderBy('status', 'asc')
  .orderBy('created_at', 'desc')
  .execute()

3.5 Aggregations

// COUNT
const { count } = await db
  .selectFrom('posts')
  .select((eb) => eb.fn.count<number>('id').as('total'))
  .where('status', '=', 'published')
  .executeTakeFirstOrThrow()

// GROUP BY
const stats = await db
  .selectFrom('posts')
  .select((eb) => [
    'author_id',
    eb.fn.count<number>('id').as('post_count'),
    eb.fn.avg<number>('view_count').as('avg_views'),
    eb.fn.max<number>('view_count').as('max_views'),
  ])
  .groupBy('author_id')
  .having((eb) => eb.fn.count('id'), '>', 5)
  .execute()
// Returns: { author_id: number; post_count: number; avg_views: number; max_views: number }[]

Part 4: Type-Safe JOINs

This is where Kysely truly shines. JOINs are fully type-safe — the result type automatically includes columns from all joined tables.

4.1 Inner Join

// Basic INNER JOIN
const postsWithAuthors = await db
  .selectFrom('posts')
  .innerJoin('users', 'users.id', 'posts.author_id')
  .select([
    'posts.id',
    'posts.title',
    'users.username as author_name',
  ])
  .execute()
// Returns: { id: number; title: string; author_name: string }[]

// Multiple JOINs
const fullPosts = await db
  .selectFrom('posts')
  .innerJoin('users', 'users.id', 'posts.author_id')
  .innerJoin('post_tags', 'post_tags.post_id', 'posts.id')
  .innerJoin('tags', 'tags.id', 'post_tags.tag_id')
  .select([
    'posts.title',
    'users.username',
    'tags.name as tag_name',
  ])
  .execute()

4.2 Left Join (Nullable Results)

// LEFT JOIN — posts may or may not have comments
const postsWithCommentCount = await db
  .selectFrom('posts')
  .leftJoin('comments', 'comments.post_id', 'posts.id')
  .select((eb) => [
    'posts.id',
    'posts.title',
    eb.fn.count<number>('comments.id').as('comment_count'),
  ])
  .groupBy(['posts.id', 'posts.title'])
  .execute()

4.3 Dynamic JOINs

// Conditionally join based on runtime parameters
function buildQuery(db: Kysely<Database>, includeAuthor: boolean) {
  let query = db
    .selectFrom('posts')
    .select(['posts.id', 'posts.title'])

  if (includeAuthor) {
    query = query
      .innerJoin('users', 'users.id', 'posts.author_id')
      .select(['users.username as author_name'])
  }

  return query.execute()
}

Part 5: INSERT

5.1 Single Insert

// Insert and return the row (PostgreSQL)
const newUser = await db
  .insertInto('users')
  .values({
    username: 'johndoe',
    email: 'john@example.com',
    password_hash: await hashPassword('securepass'),
    role: 'user',
    metadata: { login_count: 0, last_ip: null, preferences: { theme: 'dark', notifications: true } },
  })
  .returningAll()
  .executeTakeFirstOrThrow()

// Insert without returning (MySQL / SQLite)
await db
  .insertInto('users')
  .values({
    username: 'janedoe',
    email: 'jane@example.com',
    password_hash: await hashPassword('anotherpass'),
    role: 'user',
    metadata: { login_count: 0, last_ip: null, preferences: { theme: 'light', notifications: false } },
  })
  .execute()

5.2 Bulk Insert

await db
  .insertInto('tags')
  .values([
    { name: 'TypeScript', slug: 'typescript' },
    { name: 'Node.js', slug: 'nodejs' },
    { name: 'PostgreSQL', slug: 'postgresql' },
    { name: 'Kysely', slug: 'kysely' },
  ])
  .execute()

5.3 Insert with ON CONFLICT (Upsert)

// PostgreSQL: Insert or update on conflict
await db
  .insertInto('users')
  .values({
    username: 'johndoe',
    email: 'john@example.com',
    password_hash: await hashPassword('newpass'),
    role: 'user',
    metadata: { login_count: 1, last_ip: '192.168.1.1', preferences: { theme: 'dark', notifications: true } },
  })
  .onConflict((oc) =>
    oc.column('username').doUpdateSet({
      email: (eb) => eb.ref('excluded.email'),
      password_hash: (eb) => eb.ref('excluded.password_hash'),
    })
  )
  .execute()

// MySQL equivalent: INSERT ... ON DUPLICATE KEY UPDATE
await db
  .insertInto('post_tags')
  .values({ post_id: 1, tag_id: 2 })
  .onDuplicateKeyUpdate({
    post_id: 1,
    tag_id: 2,
  })
  .execute()

Part 6: UPDATE

6.1 Basic Update

// Update single row
await db
  .updateTable('users')
  .set({
    bio: 'Full-stack developer & open source enthusiast',
    updated_at: new Date(),
  })
  .where('id', '=', 1)
  .execute()

// Update with return (PostgreSQL)
const updated = await db
  .updateTable('posts')
  .set({ status: 'published', published_at: new Date() })
  .where('id', '=', 42)
  .returningAll()
  .executeTakeFirstOrThrow()

6.2 Conditional Updates

// Update with expression builder
await db
  .updateTable('posts')
  .set((eb) => ({
    view_count: eb('view_count', '+', 1),
    updated_at: new Date(),
  }))
  .where('id', '=', 42)
  .execute()

// Bulk update
await db
  .updateTable('posts')
  .set({ status: 'archived' })
  .where('created_at', '<', new Date('2025-01-01'))
  .where('status', '=', 'draft')
  .execute()

Part 7: DELETE

// Delete single row
await db
  .deleteFrom('comments')
  .where('id', '=', 100)
  .execute()

// Bulk delete
await db
  .deleteFrom('posts')
  .where('status', '=', 'draft')
  .where('created_at', '<', new Date('2025-06-01'))
  .execute()

// Delete with return (PostgreSQL)
const deleted = await db
  .deleteFrom('posts')
  .where('id', '=', 42)
  .returningAll()
  .executeTakeFirstOrThrow()

Part 8: Subqueries

8.1 Scalar Subqueries

// Subquery in SELECT
const postsWithRanking = await db
  .selectFrom('posts')
  .select((eb) => [
    'id',
    'title',
    'view_count',
    eb
      .selectFrom('posts')
      .select((eb) => eb.fn.count<number>('id').as('total'))
      .whereRef('posts.author_id', '=', 'posts.author_id')
      .as('author_post_count'),
  ])
  .execute()

8.2 Subqueries in WHERE

// Posts by authors who have more than 10 posts
const postsByProlificAuthors = await db
  .selectFrom('posts')
  .selectAll()
  .where('author_id', 'in', (qb) =>
    qb
      .selectFrom('posts')
      .select('author_id')
      .groupBy('author_id')
      .having((eb) => eb.fn.count('id'), '>', 10)
  )
  .execute()

8.3 EXISTS Subquery

// Users who have at least one published post
const usersWithPosts = await db
  .selectFrom('users')
  .selectAll()
  .where((eb) =>
    eb.exists(
      eb
        .selectFrom('posts')
        .select('id')
        .whereRef('posts.author_id', '=', 'users.id')
        .where('posts.status', '=', 'published')
    )
  )
  .execute()

Part 9: Transactions

9.1 Basic Transaction

// All-or-nothing transaction
const result = await db.transaction().execute(async (trx) => {
  // Deduct from sender
  await trx
    .updateTable('accounts')
    .set((eb) => ({ balance: eb('balance', '-', 100) }))
    .where('id', '=', senderId)
    .execute()

  // Add to receiver
  await trx
    .updateTable('accounts')
    .set((eb) => ({ balance: eb('balance', '+', 100) }))
    .where('id', '=', receiverId)
    .execute()

  // Create transaction record
  return trx
    .insertInto('transactions')
    .values({
      from_id: senderId,
      to_id: receiverId,
      amount: 100,
      created_at: new Date(),
    })
    .returningAll()
    .executeTakeFirstOrThrow()
})

9.2 Savepoints (Nested Transactions)

await db.transaction().execute(async (trx) => {
  // Outer transaction operations
  await trx.insertInto('posts').values({ ... }).execute()

  // Create a savepoint for partial rollback
  await trx
    .savepoint('after_post_insert')
    .execute(async (sp) => {
      try {
        await sp.insertInto('tags').values({ ... }).execute()
      } catch {
        // Rollback to savepoint — post is kept, tag is rolled back
        await sp.rollback('after_post_insert').execute()
      }
    })

  // Continue with outer transaction
  await trx.updateTable('users').set({ ... }).execute()
})

Part 10: Advanced Patterns

10.1 Common Table Expressions (CTEs)

// Recursive CTE for hierarchical data
const threadedComments = await db
  .with('comment_tree', (db) =>
    db
      .selectFrom('comments')
      .select(['id', 'content', 'parent_id', 'author_id'])
      .where('parent_id', 'is', null)
      .unionAll(
        db
          .selectFrom('comments')
          .innerJoin('comment_tree', 'comment_tree.id', 'comments.parent_id')
          .select([
            'comments.id',
            'comments.content',
            'comments.parent_id',
            'comments.author_id',
          ])
      )
  )
  .selectFrom('comment_tree')
  .selectAll()
  .execute()

10.2 Window Functions

// Rank posts within each author by view count
const rankedPosts = await db
  .selectFrom('posts')
  .select((eb) => [
    'id',
    'title',
    'author_id',
    'view_count',
    eb.fn
      .agg<number>('rank')
      .over((ob) =>
        ob.partitionBy('author_id').orderBy('view_count', 'desc')
      )
      .as('rank'),
  ])
  .execute()

10.3 Pivoting Data

// Pivot post statuses into columns
const pivotResult = await db
  .selectFrom('posts')
  .select((eb) => [
    'author_id',
    eb.fn.count<number>('id').filterWhere('status', '=', 'draft').as('draft_count'),
    eb.fn.count<number>('id').filterWhere('status', '=', 'published').as('published_count'),
    eb.fn.count<number>('id').filterWhere('status', '=', 'archived').as('archived_count'),
  ])
  .groupBy('author_id')
  .execute()

10.4 Raw SQL Interop

When you need database-specific features Kysely doesn’t support:

// Raw expression in otherwise type-safe query
import { sql } from 'kysely'

const result = await db
  .selectFrom('posts')
  .select((eb) => [
    'id',
    'title',
    // Use raw SQL for full-text search (PostgreSQL)
    sql<string>`plainto_tsquery('english', ${searchTerm})`.as('query'),
    sql<number>`ts_rank(to_tsvector('english', title || ' ' || content), plainto_tsquery('english', ${searchTerm}))`.as('relevance'),
  ])
  .where(
    sql`to_tsvector('english', title || ' ' || content) @@ plainto_tsquery('english', ${searchTerm})`,
    '=',
    true
  )
  .orderBy('relevance', 'desc')
  .limit(20)
  .execute()

Part 11: Type Generation from Existing Database

If you already have a database, don’t define types manually — generate them:

# PostgreSQL
npx kysely-codegen --dialect postgres --url postgresql://user:pass@localhost:5432/myapp

# MySQL
npx kysely-codegen --dialect mysql --url mysql://user:pass@localhost:3306/myapp

# SQLite
npx kysely-codegen --dialect sqlite --url ./database.db

This generates a db.d.ts file with all table and column types from your actual database schema.


Part 12: Kysely vs ORMs

When to Use Kysely

  • You want full SQL power with type safety
  • You have complex queries (multiple JOINs, CTEs, window functions)
  • You need maximum performance (no abstraction overhead)
  • You prefer writing SQL over learning ORM-specific APIs
  • You’re migrating from raw SQL and want type safety added incrementally

When to Use an ORM

  • You’re building a simple CRUD application
  • You want automatic migrations and schema management
  • You prefer entity-based thinking over table-based thinking
  • Your team is less comfortable with SQL

The Hybrid Approach

Many production applications use both:

// Use Prisma/TypeORM for simple CRUD and migrations
const user = await prisma.user.findUnique({ where: { id: 1 } })

// Use Kysely for complex analytical queries
const stats = await db
  .selectFrom('posts')
  .select((eb) => [
    'author_id',
    eb.fn.count<number>('id').as('post_count'),
    eb.fn.avg<number>('view_count').as('avg_views'),
  ])
  .groupBy('author_id')
  .having((eb) => eb.fn.count('id'), '>', 5)
  .execute()

Part 13: Production Tips

13.1 Connection Pooling

Always use connection pools in production:

import { Pool } from 'pg'

const pool = new Pool({
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20,           // Maximum pool size
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
})

const db = new Kysely<Database>({
  dialect: new PostgresDialect({ pool }),
})

13.2 Query Logging

const db = new Kysely<Database>({
  dialect: new PostgresDialect({ pool }),
  log: (event) => {
    if (event.level === 'query') {
      console.log(`[${event.queryDurationMillis}ms]`, event.query.sql)
      console.log('Parameters:', event.query.parameters)
    }
  },
})

13.3 Error Handling

import { TransactionRollbackError } from 'kysely'

try {
  await db.transaction().execute(async (trx) => {
    // ... operations
  })
} catch (error) {
  if (error instanceof TransactionRollbackError) {
    console.error('Transaction was rolled back')
  } else {
    console.error('Database error:', error)
  }
}

13.4 Graceful Shutdown

async function shutdown() {
  await db.destroy() // Closes all pool connections
  process.exit(0)
}

process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)

Part 14: Real-World Example — Blog API

Putting it all together — a complete blog service using Kysely:

import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'

// Types defined above (Database, UsersTable, PostsTable, etc.)

const db = new Kysely<Database>({
  dialect: new PostgresDialect({
    pool: new Pool({ connectionString: process.env.DATABASE_URL }),
  }),
})

// --- Service Layer ---

class PostService {
  async listPublished(page: number, limit: number) {
    const offset = (page - 1) * limit

    const [posts, { count: total }] = await Promise.all([
      db
        .selectFrom('posts')
        .innerJoin('users', 'users.id', 'posts.author_id')
        .select([
          'posts.id',
          'posts.title',
          'posts.slug',
          'posts.excerpt',
          'posts.published_at',
          'posts.view_count',
          'users.username as author_name',
        ])
        .where('posts.status', '=', 'published')
        .orderBy('posts.published_at', 'desc')
        .limit(limit)
        .offset(offset)
        .execute(),

      db
        .selectFrom('posts')
        .select((eb) => eb.fn.count<number>('id').as('count'))
        .where('status', '=', 'published')
        .executeTakeFirstOrThrow(),
    ])

    return { posts, total, page, limit, totalPages: Math.ceil(total / limit) }
  }

  async getBySlug(slug: string) {
    return db
      .selectFrom('posts')
      .innerJoin('users', 'users.id', 'posts.author_id')
      .select([
        'posts.id',
        'posts.title',
        'posts.slug',
        'posts.content',
        'posts.excerpt',
        'posts.published_at',
        'posts.view_count',
        'users.username as author_name',
        'users.avatar_url as author_avatar',
      ])
      .where('posts.slug', '=', slug)
      .where('posts.status', '=', 'published')
      .executeTakeFirst()
  }

  async create(data: CreatePostInput) {
    return db.transaction().execute(async (trx) => {
      const post = await trx
        .insertInto('posts')
        .values({
          title: data.title,
          slug: data.slug,
          content: data.content,
          excerpt: data.excerpt,
          author_id: data.authorId,
          status: 'draft',
        })
        .returningAll()
        .executeTakeFirstOrThrow()

      if (data.tags?.length) {
        await trx
          .insertInto('post_tags')
          .values(data.tags.map((tagId) => ({ post_id: post.id, tag_id: tagId })))
          .execute()
      }

      return post
    })
  }

  async incrementViews(postId: number) {
    await db
      .updateTable('posts')
      .set((eb) => ({ view_count: eb('view_count', '+', 1) }))
      .where('id', '=', postId)
      .execute()
  }

  async getStats() {
    return db
      .selectFrom('posts')
      .select((eb) => [
        'author_id',
        eb.fn.count<number>('id').as('total_posts'),
        eb.fn.count<number>('id').filterWhere('status', '=', 'published').as('published'),
        eb.fn.count<number>('id').filterWhere('status', '=', 'draft').as('drafts'),
        eb.fn.sum<number>('view_count').as('total_views'),
      ])
      .groupBy('author_id')
      .execute()
  }
}

export const postService = new PostService()

Conclusion

Kysely fills a crucial gap in the TypeScript ecosystem. It gives you:

  • Full SQL power — write any query you can write in raw SQL
  • Complete type safety — compile-time errors for column names, types, and JOINs
  • Minimal overhead — no entity management, no change tracking, no proxy magic
  • Excellent DX — IDE autocomplete, refactoring support, and clear error messages

If you’re building a TypeScript backend and you know SQL, Kysely is the tool that respects your knowledge while protecting you from typos. It’s not about choosing between SQL and type safety — it’s about having both.

The best part? You can adopt it incrementally. Start with one complex query, see the type safety in action, and expand from there.

Give it a try. Your future self will thank you at 3 AM. 🚀


Further Reading