better-effect
Integrations and adapters

Kysely integration

Preserve Kysely's native SQL boundary inside typed better-effect Programs.

better-effect-kysely is the optional, server-side integration between better-effect and Kysely. Kysely remains the SQL query builder, compiler, executor and dialect boundary. The integration adds a small bridge for Service provisioning, lazy Effect terminals and transaction outcomes; it is not an ORM or repository framework.

Install the integration

Install the integration, its peers and the driver selected by your Kysely dialect:

bun add better-effect-kysely better-effect better-result kysely
# Install one application-owned driver, for example:
bun add better-sqlite3

The package has no runtime driver dependency. Its peer ranges are:

PeerRange
better-effect>=0.13.0 <0.14.0
better-result^3.0.0
kysely>=0.29.5 <0.30.0
TypeScript>=6.0.0

Drivers such as better-sqlite3, pg, mysql2 or PGlite are explicit application choices. Importing better-effect-kysely does not select a driver, open a connection or create a database.

The integration boundary

A native Kysely query is a Promise terminal. A better-effect Program is a lazy function whose result is a better-result Result and whose declaration carries Service requirements. The bridge connects those boundaries without turning a builder into a new Effect value:

Kysely builder / schema operation / raw builder


         .$call(KyselyEffect.execute)


       lazy KyselyOperation<A, KyselyQueryError>


       Runtime resolves Services and awaits Promise


              Result<A, E>

$call is Kysely's native terminal. It preserves the builder's receiver and allows normal Kysely code to stay normal Kysely code until the point where an Effect Program needs to await it:

const users =
  yield * database.selectFrom('users').select(['id', 'email']).$call(KyselyEffect.execute)

Do not write yield* query directly. Kysely builders are not yieldable Services and the integration intentionally does not add an iterator, prototype method or module augmentation.

Define a database Service

Create one token for each schema and use it as both the Layer handle and the value yielded by a Program:

import { KyselyEffect } from 'better-effect-kysely'

interface AppDatabase {
  users: {
    id: number
    email: string
  }
}

const Database = KyselyEffect.service<AppDatabase>()('@app/Database')

The resolved value is the original Kysely<AppDatabase> reference. Its native private state, methods, plugins, builder inference and dialect remain intact:

import { Effect } from 'better-effect'
import { Result } from 'better-result'

const listUsers = Effect.fn(async function* () {
  const database = yield* Database
  const users = yield* database.selectFrom('users').selectAll().$call(KyselyEffect.execute)

  return Result.ok(users)
})

See Services for the general Service model and Layers for composition and completeness checks.

Owned and borrowed databases

Choose ownership explicitly when defining the Layer. Factories are lazy and run once per Runtime when the provider is first resolved:

Origin of the Kysely instanceAPIWho calls destroy()?
The Layer creates and owns the instanceDatabase.scoped(factory)Runtime root during shutdown
The factory borrows a pool/driver owned by another LayerDatabase.borrowed(factory)The pool/driver owner
The caller already has the instanceDatabase.succeed(database)The caller
const ownedRuntime = await Runtime.make(Database.scoped(makeDatabase))
await ownedRuntime.dispose() // destroys the owned Kysely instance

const borrowedRuntime = await Runtime.make(Database.succeed(existingDatabase))
await borrowedRuntime.dispose() // existingDatabase remains usable
await existingDatabase.destroy() // caller cleanup

scoped registers one root-Scope finalizer and reports destroy() failures as cleanup failures. borrowed and succeed never destroy their values. The There is no legacy ownership alias. Select ownership explicitly with scoped, borrowed or succeed.

Contextual factories and shared pools

scoped and borrowed accept synchronous or asynchronous generators. Services resolved with yield* become Layer.Required, so a Kysely facade can borrow a pool shared with another subsystem without adding a dialect-specific peer:

class DatabasePool extends Service<DatabasePool>()('@app/DatabasePool') {
  constructor(readonly raw: Pool) {
    super()
  }
}

const DatabaseLive = Database.borrowed(function* () {
  const pool = yield* DatabasePool
  return createDatabaseFromPool(pool.raw)
})

const AppLive = Layer.merge(Layer.succeed(DatabasePool, databasePool), DatabaseLive)

The pool owner closes the pool after request/job executions drain. The borrowed Kysely and other facades release as no-ops, preventing a double-close or a facade from closing the shared pool before its owner.

Query terminals

The runtime helpers live under the frozen KyselyEffect namespace:

HelperNative operationSuccess value
executequery.execute()The complete row array
executeWith(options)query.execute(options)The complete row array
executeTakeFirstquery.executeTakeFirst()First row or undefined
executeTakeFirstWith(options)Configured first-row terminalFirst row or undefined
executeTakeFirstOrFail(makeError)First-row terminalFirst row, or the supplied domain error
executeTakeFirstOrFailWith(options, makeError)Configured first-row-or-failFirst row, or the supplied domain error

Every helper is a lazy single-Promise bridge. It invokes the native Kysely terminal once, preserves its receiver and returns the original success value:

const inserted =
  yield *
  database
    .insertInto('users')
    .values({ id: 1, email: 'ada@example.test' })
    .returningAll()
    .$call(KyselyEffect.execute)

const optional =
  yield *
  database.selectFrom('users').selectAll().where('id', '=', id).$call(KyselyEffect.executeTakeFirst)

Schema builders and mutations use exactly the same boundary:

yield *
  database.schema
    .createTable('users')
    .addColumn('id', 'integer', (column) => column.primaryKey())
    .addColumn('email', 'text', (column) => column.notNull())
    .$call(KyselyEffect.execute)

First-row absence

executeTakeFirst models an absent row as undefined. Use an or-fail helper when absence is part of the application contract:

class UserNotFound extends Error {
  constructor(readonly userId: number) {
    super(`User ${userId} was not found`)
  }
}

const user =
  yield *
  database
    .selectFrom('users')
    .selectAll()
    .where('id', '=', userId)
    .$call(KyselyEffect.executeTakeFirstOrFail(() => new UserNotFound(userId)))

Only strict undefined invokes makeError. A row containing nullable columns, false, 0, an empty string or null is still a successful row. The error is a typed Result error rather than Kysely's uncaught executeTakeFirstOrThrow defect.

Raw and compiled queries

Use executeQuery(database, query, options?) when the application needs Kysely's complete QueryResult rather than only its rows. It accepts a native RawBuilder, a structural Compilable or a CompiledQuery:

import { sql } from 'kysely'

const raw =
  yield * KyselyEffect.executeQuery(database, sql<{ value: number }>`select ${1} as value`)

const compiled = database
  .selectFrom('users')
  .select(['id', 'email'])
  .where('id', '=', userId)
  .compile()
const compiledResult = yield * KyselyEffect.executeQuery(database, compiled)

The result retains rows and dialect metadata such as affected-row counts. A real RawBuilder uses Kysely's native RawBuilder executor, so its plugins and result transformation run normally. A CompiledQuery keeps its SQL and parameters; the bridge does not compile it a second time. The executor may be the outer Kysely<DB> or a native Transaction<DB>.

Transactions

KyselyEffect.transaction bridges Kysely's callback transaction to a lazy Effect.Program. Kysely begins the native transaction first, then invokes the factory with the native Transaction<DB>:

const createUser = Effect.fn(async function* () {
  const database = yield* Database

  const user = yield* KyselyEffect.transaction(database, (transaction) =>
    Effect.fn(async function* () {
      const created = yield* transaction
        .insertInto('users')
        .values({ id: 1, email: 'ada@example.test' })
        .returningAll()
        .$call(KyselyEffect.executeTakeFirstOrFail(() => new Error('no row returned')))

      return Result.ok(created)
    })
  )

  return Result.ok(user)
})

The body uses transaction explicitly. The outer Database Service is not replaced, no nested Runtime is created, and other Services from the existing Runtime remain available inside the body:

const operation = KyselyEffect.transaction(database, (transaction) =>
  Effect.fn(async function* () {
    const audit = yield* AuditService
    audit.record('user.create')
    yield* transaction
      .insertInto('users')
      .values({ id: 1, email: 'ada@example.test' })
      .$call(KyselyEffect.execute)
    return Result.ok(undefined)
  })
)

Transaction state machine

               native begin


             ┌─────────────┐
             │ body runs   │◄──── additional Services use current Runtime
             └──────┬──────┘

         ┌──────────┼──────────┐
         │          │          │
   Result.ok   Result.err   throw/reject/abort
         │          │          │
         ▼          ▼          ▼
      commit     rollback     rollback
         │          │          │
         └──────────┴──────────┘

              release native

Result.ok(value) commits and exposes value. Every Result.err(error) rolls back, including a query operation error, and restores that exact error when rollback succeeds. If rollback itself fails, the result is a KyselyTransactionError whose non-enumerable .bodyFailure retains the typed body error. Thrown defects and abort reasons roll back and are rethrown; when cleanup also fails, their aggregate keeps the body cause first. Native begin or commit failures are represented by KyselyTransactionError.

A successful transaction can still surface native cleanup failure. The transaction bridge does not add retries, savepoints or controlled-transaction semantics.

KyselyTransactionOptions forwards Kysely's native isolationLevel and accessMode:

const readOnly = KyselyEffect.transaction(
  database,
  { isolationLevel: 'serializable', accessMode: 'read only' },
  (transaction) =>
    Effect.fn(async function* () {
      const rows = yield* transaction.selectFrom('users').selectAll().$call(KyselyEffect.execute)
      return Result.ok(rows)
    })
)

The supported values and their effect depend on the selected dialect. They are not emulated by the integration.

Cancellation

Pass a request or shutdown signal to a Runtime execution:

const result = await runtime.run(listUsers, { signal: request.signal })

The integration links that signal to the single Kysely Promise boundary. Query options expose Kysely's inflightQueryAbortStrategy:

const execute = KyselyEffect.executeWith({
  inflightQueryAbortStrategy: 'cancel query'
})
const users = yield * database.selectFrom('users').selectAll().$call(execute)

The public KyselyExecutionOptions intentionally does not accept signal. Runtime owns the signal and supplies a fresh linked option set for each execution. The strategies mean:

StrategyMeaning
ignore queryStop waiting according to Kysely's policy; server work may continue
cancel queryAsk a capable driver to cancel the query
kill sessionAsk a capable driver to terminate its session

Cancellation support is dialect- and driver-dependent. It is not a universal server-side guarantee, and a write may already have been applied. The public Kysely callback API also leaves a small unavoidable race between the final transaction cancellation check and native commit.

For the core Runtime lifecycle and signal ownership, see Runtime.

Errors and failure precedence

KyselyQueryError wraps a query Promise failure. KyselyTransactionError represents native transaction or cleanup failures. Both preserve the original cause in memory while keeping it out of normal enumeration and toJSON():

const result = await runtime.run(listUsers)
if (Result.isError(result)) {
  console.error(result.error.name)
}

Messages and serialized fields do not contain SQL, parameters, credentials or driver-specific details. Inspect .cause only at an intentional trusted diagnostic boundary; do not put it directly in an HTTP response or structured log by default. Map application domain errors explicitly with the patterns in Errors and Results.

A domain Result.err is not silently converted into a generic query error. When cleanup succeeds, the body failure remains the primary application error. When typed rollback cleanup also fails, KyselyTransactionError.bodyFailure retains that body error; defects and abort reasons are composed body-first with the cleanup cause. A successful transaction exposes cleanup failure rather than its value.

Multiple database Services

Use distinct literal tags for distinct database instances, even if their table shapes overlap:

const PrimaryDatabase = KyselyEffect.service<PrimarySchema>()('@app/PrimaryDatabase')
const AnalyticsDatabase = KyselyEffect.service<AnalyticsSchema>()('@app/AnalyticsDatabase')

const AppLive = Layer.merge(
  PrimaryDatabase.scoped(createPrimaryDatabase),
  AnalyticsDatabase.scoped(createAnalyticsDatabase)
)

Layer composition rejects duplicate tags rather than choosing a provider by accident. The two resolved values remain native Kysely instances with their own schema inference.

Composing with repositories

Keep business behavior in application Services or ordinary modules. A repository can depend on the database Service without moving business logic into Kysely or a dependency container:

UserRepository Service
        │ yield* Database

Kysely query builder
        │ .$call(KyselyEffect.execute)

Result<User[], KyselyQueryError | DomainError>

This boundary keeps the repository responsible for domain mapping and Kysely responsible for SQL typing. better-effect-kysely only adapts the Promise terminal and database lifetime.

Testing the integration

Use a real dialect for integration tests and a borrowed Layer when the test creates and destroys the native database itself:

const database = makeInMemoryDatabase()
const runtime = await Runtime.make(Database.succeed(database))

try {
  const result = await runtime.run(listUsers)
  // Assert the Result and native rows.
} finally {
  await runtime.dispose()
  await database.destroy()
}

Use an owned Layer when the test is specifically verifying Runtime lifecycle: assert that destroy() runs once after success and failure. Test raw and compiled queries, plugin transformation, logger events, transaction rollback and dialect-native values against the actual driver. Avoid replacing every Kysely builder with a mock when the behavior under test is the compiler or executor boundary.

The package release suite runs deterministic real-database tests with Bun SQLite and PGlite. It also checks PostgreSQL, MySQL and SQLite Kysely types without opening external servers. PGlite evidence should not be read as a claim that every pg version or deployment behaves identically.

See Testing for general Layer overrides and Runtime test helpers.

Compatibility and limitations

The 0.1.x integration is validated with:

  • the latest Bun release and the current Node.js LTS;
  • TypeScript 6.0 or newer, with the repository using TypeScript 7.x;
  • Kysely 0.29.5, which is both the minimum and current tested version for this release;
  • Bun's built-in SQLite adapter and PGlite 0.5.8 for real database runs;
  • better-sqlite3 12.4.1 in the external Node.js consumer cell. The Bun SQLite cell uses bun:sqlite because better-sqlite3 is not supported by Bun.

The runtime source is dialect-agnostic and bundles no driver. Driver errors, feature support, cancellation, returned scalar representations and metadata remain native to the selected Kysely dialect.

This integration deliberately does not provide:

  • migrations or schema management beyond Kysely's own schema builder;
  • streaming, cursors or result backpressure;
  • controlled transactions, savepoints or automatic retries;
  • schema codecs or runtime result validation;
  • OpenTelemetry/RuntimeObserver integration;
  • a repository, ORM or dependency-container framework;
  • directly yieldable Kysely builders.

Public API

Runtime helpers are namespaced under KyselyEffect:

NameKindPurpose
KyselyEffectruntime namespaceService factory, query terminals, executeQuery and transaction
KyselyOperationtypeOperation value, error and requirement channels
KyselyExecutionOptionstypeKysely abort strategy options without a second signal
KyselyTransactionOptionstypeNative isolation and access mode options
KyselyQueryOperationtypeQuery operation names used by safe errors
KyselyServiceInstancetypeTagged native Kysely instance contract
KyselyServiceTokentypeTagged Kysely Service token
KyselyServicetypeNative Kysely<DB> contract
KyselyExecutabletypeStructural execute terminal
KyselyTakeFirstExecutabletypeStructural first-row terminal
KyselyQueryErrorclassSafe query boundary failure
KyselyTransactionErrorclassSafe transaction/cleanup failure

The root exports intentionally do not expose sentinels, transaction outcomes, Promise normalizers, private error helpers, source paths or dialect drivers.

Further reading

On this page