Linting Kysely Queries with Biome and Grit
I’ve been using Kysely heavily recently. It’s great for type-safety, but it has a specific pitfall: it is very easy to write a valid TypeScript expression that creates a query builder but never actually executes it.
The Solution: A Biome plugin
Biome is my linter of choice for JavaScript and TypeScript projects and i wanted to see if I could use it to catch these cases with a simple plugin. The chosen language for Biome plugins is Grit, a query language that, although it has poor documentation, is powerful if you understand how to use it.
Missing select/selectAll clauses
The first issue I wanted to solve was ensuring every select query actually has a select clause. It’s easy to forget to add .selectAll() or .select('id'), which results in no data being fetched.
// This compiles, runs, but doesn't fetch any data!
db.selectFrom('users').where("id", "==", 1).executeTakeFirst();
Standard regex search doesn't work well here because queries can be multi-line or nested. We need to understand the code structure (AST).
My solution:
language js(typescript)
`$chain.$exec()` where {
$exec <: or { `execute`, `executeTakeFirst`, `executeTakeFirstOrThrow` },
$chain <: contains `selectFrom`,
! $chain <: contains `select`,
! $chain <: contains `selectAll`,
register_diagnostic(span=$chain, message="select statement must contain select() or selectAll()", severity="error")
}
This pattern looks for any execution method (execute, executeTakeFirst, etc.) that is part of a chain containing selectFrom. It then checks if that chain does NOT contain either select or selectAll. If both conditions are met, it registers a lint error.
Missing where clauses
Another common mistake is forgetting to add a where clause to an update or delete query, which can lead to unintentional mass updates or deletions.
// the code syntax is valid, but it will delete all users in the database!
db.deleteFrom('users').execute();
Similarly to the previous case, we can write a Grit pattern to search for execution of update or delete queries that do not contain a where clause.
My solution:
language js(typescript)
`$chain.$exec()` where {
$exec <: or { `execute`, `executeTakeFirst`, `executeTakeFirstOrThrow` },
or { $chain <: contains `updateTable`, $chain <: contains `deleteFrom` },
! $chain <: contains `where`,
register_diagnostic(span=$chain, message="update or delete statements must contain where()", severity="error")
}
Incorrect null checks
It often happens that values in the database can be null. Kysely uses is and is not for null checks, but it’s easy to accidentally use == or !=, which will not work as intended and will not return any results.
// fine syntax but it will not return any results because '==' does not work for null checks in Kysely
db.selectFrom('users').where("optionalFlag", "==", null).executeTakeFirst()
// the correct way to check for null in Kysely is using 'is' or 'is not'
db.selectFrom('users').where("optionalFlag", "is", null).executeTakeFirst()
This is a bit more complex to catch because we need to look for any comparison operator (=, ==, !=, !==) that is comparing a value to null within a where clause.
language js
`$fn($a)` where {
$fn <: contains `where`,
or {
$a <: contains `'='`,
$a <: contains `'=='`,
$a <: contains `'!='`,
$a <: contains `'!=='`
},
$a <: contains `null`,
register_diagnostic(span=$a, message="Comparisons to null using '=' or '==' are not allowed. Use 'is' or 'is not' instead.", severity="error")
}
Usage
If you are already using Biome, you can add some or all of these patterns to your project and reference them in your Biome configuration.
biome.json:
{
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"plugins": [
"biome_plugins/missing_select.grit",
"biome_plugins/missing_where.grit",
"biome_plugins/null.grit"
],
}
Summary
It’s a simple implementation, but it removes a class of bugs that TypeScript static analysis misses. If you are using Kysely and want to strictly enforce query execution, the source is available here: