Kysely’s primary goal is to provide end-to-end type safety for SQL queries. Every column reference, table name, and return type is validated at compile time.
Database Type Parameter
Type safety starts with the database type parameter passed to Kysely<DB>:
import { Kysely, Generated } from 'kysely'
interface Database {
person: {
id: Generated<number>
first_name: string
last_name: string | null
age: number
}
pet: {
id: Generated<number>
name: string
owner_id: number
species: string
}
}
const db = new Kysely<Database>({
dialect: /* ... */
})
This single type parameter powers all type inference throughout Kysely.
Column Selection Type Inference
Kysely infers the return type based on selected columns:
// Type: { first_name: string; age: number }[]
const result = await db
.selectFrom('person')
.select(['first_name', 'age'])
.execute()
result[0].first_name // ✓ string
result[0].age // ✓ number
result[0].last_name // ✗ Error: Property 'last_name' does not exist
Aliased Columns
// Type: { firstName: string; personAge: number }[]
const result = await db
.selectFrom('person')
.select([
'first_name as firstName',
'age as personAge'
])
.execute()
result[0].firstName // ✓ string
result[0].personAge // ✓ number
result[0].first_name // ✗ Error: Property 'first_name' does not exist
SelectAll
// Type: { id: number; first_name: string; last_name: string | null; age: number }[]
const result = await db
.selectFrom('person')
.selectAll()
.execute()
Table Reference Validation
Only tables defined in the database interface can be referenced:
// ✓ Valid
await db.selectFrom('person').selectAll().execute()
// ✗ Error: Argument of type '"invalid_table"' is not assignable
await db.selectFrom('invalid_table').selectAll().execute()
Column Reference Validation
Columns must exist in the selected table(s):
// ✓ Valid
await db
.selectFrom('person')
.select(['first_name', 'age'])
.execute()
// ✗ Error: Type '"invalid_column"' is not assignable
await db
.selectFrom('person')
.select(['first_name', 'invalid_column'])
.execute()
JOIN Type Safety
After a join, columns from both tables are available:
const result = await db
.selectFrom('person')
.innerJoin('pet', 'pet.owner_id', 'person.id')
.select([
'person.first_name', // ✓ Available
'pet.name', // ✓ Available
'person.age', // ✓ Available
'pet.species' // ✓ Available
])
.execute()
// Type: { first_name: string; name: string; age: number; species: string }[]
Join Reference Validation
// ✓ Valid - columns exist in both tables
await db
.selectFrom('person')
.innerJoin('pet', 'pet.owner_id', 'person.id')
.selectAll()
.execute()
// ✗ Error - invalid column reference
await db
.selectFrom('person')
.innerJoin('pet', 'pet.invalid_column', 'person.id')
.selectAll()
.execute()
WHERE Clause Type Safety
WHERE clauses are validated against available columns and their types:
// ✓ Valid - comparing number to number
await db
.selectFrom('person')
.where('age', '>', 18)
.selectAll()
.execute()
// ✓ Valid - comparing string to string
await db
.selectFrom('person')
.where('first_name', '=', 'Jennifer')
.selectAll()
.execute()
// ✗ Error - comparing number to string
await db
.selectFrom('person')
.where('age', '=', 'invalid') // Type error
.selectAll()
.execute()
INSERT Type Safety
Inserted values must match the table’s Insertable type:
interface PersonTable {
id: Generated<number> // Optional in insert
first_name: string // Required
last_name: string | null // Can be null
age: number // Required
}
// ✓ Valid
await db.insertInto('person').values({
first_name: 'Jennifer',
last_name: 'Aniston',
age: 40
}).execute()
// ✓ Valid - id is optional (Generated)
await db.insertInto('person').values({
first_name: 'Arnold',
last_name: null,
age: 70
}).execute()
// ✗ Error - missing required field 'first_name'
await db.insertInto('person').values({
last_name: 'Aniston',
age: 40
}).execute()
// ✗ Error - wrong type for 'age'
await db.insertInto('person').values({
first_name: 'Jennifer',
last_name: 'Aniston',
age: 'forty' // Type error
}).execute()
UPDATE Type Safety
Updated values must match the table’s Updateable type:
interface PersonTable {
id: Generated<number>
first_name: string
last_name: string | null
created_at: ColumnType<Date, Date, never> // Cannot be updated
}
// ✓ Valid
await db.updateTable('person')
.set({ age: 41 })
.where('id', '=', 1)
.execute()
// ✓ Valid - all fields are optional in updates
await db.updateTable('person')
.set({ first_name: 'Jenny' })
.where('id', '=', 1)
.execute()
// ✗ Error - created_at cannot be updated (type is 'never')
await db.updateTable('person')
.set({ created_at: new Date() })
.where('id', '=', 1)
.execute()
Return Type Inference
Return types change based on execution method:
interface Person {
id: number
first_name: string
last_name: string | null
age: number
}
// Type: Person[]
const all = await db
.selectFrom('person')
.selectAll()
.execute()
// Type: Person | undefined
const first = await db
.selectFrom('person')
.selectAll()
.executeTakeFirst()
// Type: Person (or throws)
const firstOrThrow = await db
.selectFrom('person')
.selectAll()
.executeTakeFirstOrThrow()
if (first) {
first.first_name // ✓ Type narrowing works
}
Expression Builder Type Safety
The expression builder provides type-safe access to columns in callbacks:
await db
.selectFrom('person')
.select((eb) => [
'id',
'first_name',
eb.fn.count('id').as('person_count')
])
.where((eb) => eb.or([
eb('age', '<', 18),
eb('age', '>', 65)
]))
.execute()
The eb parameter knows which columns are available in the current context:
await db
.selectFrom('person')
.innerJoin('pet', 'pet.owner_id', 'person.id')
.where((eb) => eb.or([
eb('person.age', '>', 50), // ✓ Valid
eb('pet.species', '=', 'dog') // ✓ Valid
]))
.selectAll()
.execute()
Type Narrowing with Generics
Kysely’s query builders are generic, allowing for composable, type-safe functions:
function withMinAge<DB, TB extends keyof DB, O>(
qb: SelectQueryBuilder<DB, TB, O>,
minAge: number
) {
return qb.where('age', '>=', minAge)
}
const adults = await withMinAge(
db.selectFrom('person').selectAll(),
18
).execute()
Dynamic Table Access
For runtime-dynamic table access, use db.dynamic.ref():
const tableName = 'person' as string // Runtime value
const columnName = 'first_name' as string
await db
.selectFrom(db.dynamic.ref(tableName))
.select([db.dynamic.ref(columnName)])
.execute()
// Note: This bypasses type checking!
Using db.dynamic bypasses Kysely’s type safety. Only use it when absolutely necessary.
Compile-Time vs Runtime
Kysely provides compile-time type safety. Type information is erased at runtime:
// TypeScript compilation:
// ✓ Type checks pass, everything is valid
const query = db
.selectFrom('person')
.select(['first_name', 'age'])
.where('age', '>', 18)
// Runtime execution:
// If 'person' table doesn't exist, you'll get a runtime SQL error
// Kysely cannot validate your database schema at runtime
Use database migration tools and testing to ensure your TypeScript types match your actual database schema.
Best Practices
Keep your database interface in sync with your actual database schema using migrations or code generation tools.
Use Selectable, Insertable, and Updateable type helpers to extract operation-specific types from your table interfaces.
Avoid using any or type assertions. If you need dynamic queries, use db.dynamic sparingly and document why it’s necessary.
Kysely’s type safety is structural, not nominal. Two tables with identical column types are considered compatible.