Commit 9345ca5
refactor(sql-ast): replace instanceof with kind discriminants (#253)
closes
[TML-2096](https://linear.app/prisma-company/issue/TML-2096/avoid-instanceof-in-sql-query-ast-methods)
## Before / After
```ts
// BEFORE — dispatch on class identity (breaks with duplicate packages)
if (ast instanceof SelectAst) { ... }
else if (ast instanceof InsertAst) { ... }
// fields typed as abstract classes — consumers must cast to narrow
const expr: Expression = binary.left;
const node = expr as AnyExpression;
switch (node.kind) { ... }
```
```ts
// AFTER — dispatch on structural kind tag (works across module boundaries)
switch (ast.kind) {
case 'select': ...
case 'insert': ...
default: { const _: never = ast; throw new Error(...); }
}
// fields typed as unions — consumers narrow directly
const expr: AnyExpression = binary.left;
switch (expr.kind) { ... } // no cast needed
```
## Intent
Replace all `instanceof`-based dispatch on SQL query AST node classes
with structural `kind` discriminant tags, then make discriminated union
types the public API surface for all polymorphic AST references.
Abstract base classes become internal implementation details.
`instanceof` checks fail silently when duplicate copies of a package
exist in `node_modules` (a common pnpm workspace scenario). Structural
`kind` dispatch eliminates this failure mode and enables TypeScript
exhaustive `switch` checking at every dispatch site. Using union types
for fields and parameters means `switch (node.kind)` narrows without
casting.
## Change map
### Phase 1: Kind discriminants
- **Implementation (AST definitions)**:
-
[relational-core/src/ast/types.ts](packages/2-sql/4-lanes/relational-core/src/ast/types.ts)
— `kind` tags on 28 concrete classes, narrowed `kind` unions on 5
abstract classes, 5 discriminated union types
-
[relational-core/src/utils/guards.ts](packages/2-sql/4-lanes/relational-core/src/utils/guards.ts)
— simplified `getColumnInfo`
- **Implementation (dispatch migration, 13 files across 6 packages)**:
-
[postgres/src/core/adapter.ts](packages/3-targets/6-adapters/postgres/src/core/adapter.ts)
— 39 checks
-
[sql-runtime/src/plugins/lints.ts](packages/2-sql/5-runtime/src/plugins/lints.ts)
— 6 checks
-
[sql-runtime/src/plugins/budgets.ts](packages/2-sql/5-runtime/src/plugins/budgets.ts)
— 2 checks
-
[sql-orm-client/src/where-binding.ts](packages/3-extensions/sql-orm-client/src/where-binding.ts),
[collection.ts](packages/3-extensions/sql-orm-client/src/collection.ts),
[query-plan-aggregate.ts](packages/3-extensions/sql-orm-client/src/query-plan-aggregate.ts),
[query-plan-meta.ts](packages/3-extensions/sql-orm-client/src/query-plan-meta.ts)
— 19 checks
-
[kysely-lane/src/transform/](packages/2-sql/4-lanes/kysely-lane/src/transform/)
(3 files),
[where-expr.ts](packages/2-sql/4-lanes/kysely-lane/src/where-expr.ts) —
17 checks
-
[sql-lane/src/sql/predicate-builder.ts](packages/2-sql/4-lanes/sql-lane/src/sql/predicate-builder.ts)
— 5 checks
- **Tests (evidence)**:
-
[kind-discriminants.test.ts](packages/2-sql/4-lanes/relational-core/test/ast/kind-discriminants.test.ts)
— foundation tests: all 28 tags, uniqueness, cross-module structural
dispatch
- 31 test files migrated from `toBeInstanceOf` to `.kind` assertions
### Phase 2: Union-typed fields
- **Implementation (AST core)**:
-
[relational-core/src/ast/types.ts](packages/2-sql/4-lanes/relational-core/src/ast/types.ts)
— un-export 6 abstract classes, update ~15 field types + constructors +
method return types to union types, update
`ExpressionRewriter`/`AstRewriter` interfaces, remove `SqlComparable`
alias, add `AnySqlComparable`/`AnyOperationArg` union types, make
`rewriteComparable`/`foldComparable` exhaustive
-
[relational-core/src/plan.ts](packages/2-sql/4-lanes/relational-core/src/plan.ts),
[src/types.ts](packages/2-sql/4-lanes/relational-core/src/types.ts),
[src/ast/join.ts](packages/2-sql/4-lanes/relational-core/src/ast/join.ts),
[src/operations-registry.ts](packages/2-sql/4-lanes/relational-core/src/operations-registry.ts)
— relational-core internal consumers
- **Implementation (propagation, 24 files across 5 packages)**:
-
[kysely-lane/src/transform/](packages/2-sql/4-lanes/kysely-lane/src/transform/)
— `WhereExpr` → `AnyWhereExpr`, `ColumnRef` casts removed
- [sql-lane/src/sql/](packages/2-sql/4-lanes/sql-lane/src/sql/) —
`Expression` → `AnyExpression`, `WhereExpr` → `AnyWhereExpr`,
`SqlComparable` → `AnySqlComparable`
- [sql-runtime/src/](packages/2-sql/5-runtime/src/) — `QueryAst` →
`AnyQueryAst`, `FromSource` → `AnyFromSource`, exhaustive switches added
- [sql-orm-client/src/](packages/3-extensions/sql-orm-client/src/) (~12
files) — all abstract class imports → union types
- [postgres/src/](packages/3-targets/6-adapters/postgres/src/) —
`QueryAst` → `AnyQueryAst`, `Expression` → `AnyExpression`, `FromSource`
→ `AnyFromSource`, `WhereExpr` → `AnyWhereExpr`, casts removed, error
messages aligned to `kind`-based pattern
- **Tests (evidence)**:
-
[adapter.test.ts](packages/3-targets/6-adapters/postgres/test/adapter.test.ts)
— `class UnsupportedAst extends QueryAst` replaced with plain-object
cast (abstract class no longer importable)
-
[test-helpers.ts](packages/2-sql/4-lanes/relational-core/test/ast/test-helpers.ts),
[rich-ast.test.ts](packages/2-sql/4-lanes/relational-core/test/ast/rich-ast.test.ts)
— `Expression | ParamRef | LiteralExpr` → `AnyOperationArg`
- 5 ORM client test files updated from `WhereExpr` to `AnyWhereExpr`
type annotations
## The story
### Phase 1: Kind discriminants
1. **Add structural `kind` tags to every AST node.** Each of the 28
concrete AST classes gets `readonly kind = '<tag>' as const`. The root
`AstNode` declares `abstract readonly kind: string`, and the 5
intermediate abstract classes (`QueryAst`, `FromSource`, `Expression`,
`WhereExpr`, `InsertOnConflictAction`) narrow it to their valid tag
unions — so adding a new subclass with the wrong `kind` is a compile
error.
2. **Export discriminated union types.** `AnyQueryAst`, `AnyFromSource`,
`AnyExpression`, `AnyWhereExpr`, and `AnyInsertOnConflictAction` enable
`switch (node.kind)` with exhaustive `never` defaults in dispatch sites
that need full type narrowing.
3. **Migrate all ~112 production `instanceof` checks to `kind`-based
dispatch.** Multi-branch chains become exhaustive `switch` statements.
Single-branch guards become `if (node.kind === 'tag')`. Classes imported
solely for `instanceof` are converted to type-only imports, reducing
runtime coupling.
4. **Migrate all ~117 test `toBeInstanceOf` assertions.** Test
assertions now validate the `kind` discriminant rather than class
identity, ensuring tests exercise the structural dispatch mechanism.
5. **Clean up follow-on sites.** The `expressionKinds` Set in
`predicate-builder.ts` (redundant now that `Expression.kind` is
type-narrowed) is removed. The `whereExprKinds` Set in `collection.ts`
is typed against `WhereExpr['kind']`. A dead branch in `JoinAst.rewrite`
(predating this migration) is removed.
### Phase 2: Union-typed fields
6. **Replace abstract class types with discriminated unions
everywhere.** All ~15 field declarations, constructor parameters,
composite type aliases (`ProjectionExpr`, `JoinOnExpr`, `WhereArg`),
`ExpressionRewriter`/`AstRewriter` interface return types, and abstract
method return types (`rewrite()`, `not()`, `toExpr()`) are updated to
reference union types instead of abstract base classes. `SqlComparable`
is removed in favor of `AnySqlComparable`.
7. **Propagate union types to all downstream packages.** 24 files across
`kysely-lane`, `sql-lane`, `sql-runtime`, `sql-orm-client`, and
`postgres` are updated. All imports of abstract base classes for type
annotations are replaced with union type imports. Casts that existed
solely for `kind`-narrowing (e.g., `const node = ast as AnyQueryAst`)
are removed.
8. **Un-export all 6 abstract base classes.** `AstNode`, `QueryAst`,
`FromSource`, `Expression`, `WhereExpr`, and `InsertOnConflictAction`
become module-private. They remain in the file for method inheritance
but are no longer part of the public API surface.
## Behavior changes & evidence
- **Every concrete AST class exposes a `readonly kind` discriminant with
a unique kebab-case literal type.** Dispatch sites can structurally
identify nodes without relying on class identity.
- **Why**: `instanceof` fails silently when multiple copies of a package
coexist in `node_modules`. Structural `kind` tags work regardless of
module resolution.
- **Implementation**:
[types.ts](packages/2-sql/4-lanes/relational-core/src/ast/types.ts)
- **Tests**:
[kind-discriminants.test.ts](packages/2-sql/4-lanes/relational-core/test/ast/kind-discriminants.test.ts)
— tests all 28 tags, uniqueness, and cross-module plain-object dispatch
- **Intermediate abstract classes narrow `kind` to their valid tag
union.** Adding a new `Expression` subclass with `kind = 'invalid'` is a
compile error.
- **Why**: Prevents silent dispatch gaps when the class hierarchy grows.
- **Implementation**:
[types.ts](packages/2-sql/4-lanes/relational-core/src/ast/types.ts)
- **All production dispatch uses `kind`-based switching.** Zero
`instanceof` on AST classes remain in production code.
- **Implementation**: 13 source files across 6 packages (see change map)
- **Tests**: Full test suite passes
- **All polymorphic AST fields, parameters, and return types use
discriminated union types.** `switch (node.kind)` narrows directly
without casting.
- **Why**: Abstract class types could not be narrowed by `kind` checks —
consumers had to cast to the union first. Union types eliminate this
friction.
- **Implementation**:
[types.ts](packages/2-sql/4-lanes/relational-core/src/ast/types.ts) + 24
downstream files
- **Abstract base classes are no longer exported.** They serve only as
internal implementation details for method inheritance and immutability
(`freeze()`).
- **Why**: With union types as the public API, there is no reason for
consumers to reference the abstract classes.
- **Implementation**:
[types.ts](packages/2-sql/4-lanes/relational-core/src/ast/types.ts)
- **Tests**:
[adapter.test.ts](packages/3-targets/6-adapters/postgres/test/adapter.test.ts)
— `UnsupportedAst` class replaced with plain-object cast
- **No runtime behavior change**: the class hierarchy is preserved. Both
phases are pure dispatch-mechanism and type-system migrations.
## Compatibility / migration / risk
- **No breaking API changes for in-repo consumers.** The `kind` property
is additive; fields gain more precise types (narrower unions instead of
broad abstract class).
- **External consumers that subclass abstract AST classes will break.**
Since the abstract classes are no longer exported, code outside this
repo that extends `QueryAst`, `Expression`, etc. will fail to compile.
This is intentional.
- **`toEqual` assertions against plain objects break** if the class now
has a `kind` property that the plain object lacks. All affected tests
were updated to use AST constructors (e.g. `ProjectionItem.of(...)`
instead of `{ alias, expr }`).
## Follow-ups / open questions
- ~90+ `toBeInstanceOf` calls on non-AST classes (e.g. `Collection`,
`PostCollection`) remain untouched — those are outside the scope of this
migration.
## Non-goals / intentionally out of scope
- Removing the class hierarchy itself (classes are preserved for method
inheritance, immutability via `freeze()`, and factory methods).
- Adding generics (CRTP / F-bounded polymorphism) to the abstract
classes (evaluated and rejected).
- Migrating `instanceof` on non-AST types (e.g. `Error`, `Date`,
`Collection`).
---------
Co-authored-by: Alexey Orlenko <alex@aqrln.net>1 parent 9fd4f0c commit 9345ca5
85 files changed
Lines changed: 1798 additions & 978 deletions
File tree
- .claude/scripts
- packages
- 2-sql
- 4-lanes
- kysely-lane/src
- transform
- relational-core
- src
- ast
- utils
- test
- ast
- sql-lane
- src/sql
- test
- 5-runtime
- src
- plugins
- test
- 3-extensions/sql-orm-client
- src
- test
- integration
- 3-targets/6-adapters/postgres
- src
- core
- exports
- test
- projects/orm-client
- plans
- specs
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
5 | 4 | | |
| 5 | + | |
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
| 5 | + | |
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| |||
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
102 | | - | |
| 102 | + | |
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
| |||
275 | 275 | | |
276 | 276 | | |
277 | 277 | | |
278 | | - | |
| 278 | + | |
279 | 279 | | |
280 | 280 | | |
281 | 281 | | |
| |||
Lines changed: 2 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | | - | |
| 3 | + | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
| 6 | + | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| |||
Lines changed: 2 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
40 | | - | |
| 40 | + | |
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
| |||
Lines changed: 9 additions & 9 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
| 2 | + | |
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
| 6 | + | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| |||
113 | 113 | | |
114 | 114 | | |
115 | 115 | | |
116 | | - | |
| 116 | + | |
117 | 117 | | |
118 | 118 | | |
119 | 119 | | |
| |||
159 | 159 | | |
160 | 160 | | |
161 | 161 | | |
162 | | - | |
| 162 | + | |
163 | 163 | | |
164 | 164 | | |
165 | 165 | | |
| |||
173 | 173 | | |
174 | 174 | | |
175 | 175 | | |
176 | | - | |
| 176 | + | |
177 | 177 | | |
178 | 178 | | |
179 | 179 | | |
180 | 180 | | |
181 | 181 | | |
182 | 182 | | |
183 | 183 | | |
184 | | - | |
| 184 | + | |
185 | 185 | | |
186 | 186 | | |
187 | 187 | | |
| |||
252 | 252 | | |
253 | 253 | | |
254 | 254 | | |
255 | | - | |
| 255 | + | |
256 | 256 | | |
257 | | - | |
258 | | - | |
| 257 | + | |
| 258 | + | |
259 | 259 | | |
260 | 260 | | |
261 | 261 | | |
| |||
Lines changed: 29 additions & 32 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
10 | | - | |
11 | 10 | | |
12 | | - | |
| 11 | + | |
13 | 12 | | |
14 | 13 | | |
15 | 14 | | |
| |||
21 | 20 | | |
22 | 21 | | |
23 | 22 | | |
24 | | - | |
25 | | - | |
26 | | - | |
27 | | - | |
28 | 23 | | |
29 | 24 | | |
30 | 25 | | |
| |||
49 | 44 | | |
50 | 45 | | |
51 | 46 | | |
52 | | - | |
| 47 | + | |
53 | 48 | | |
54 | 49 | | |
55 | 50 | | |
| |||
68 | 63 | | |
69 | 64 | | |
70 | 65 | | |
71 | | - | |
| 66 | + | |
72 | 67 | | |
73 | 68 | | |
74 | 69 | | |
| |||
77 | 72 | | |
78 | 73 | | |
79 | 74 | | |
80 | | - | |
| 75 | + | |
| 76 | + | |
81 | 77 | | |
82 | | - | |
83 | | - | |
84 | | - | |
85 | | - | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
86 | 82 | | |
87 | 83 | | |
88 | 84 | | |
89 | | - | |
90 | | - | |
91 | | - | |
92 | | - | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
93 | 89 | | |
94 | 90 | | |
95 | 91 | | |
| |||
105 | 101 | | |
106 | 102 | | |
107 | 103 | | |
108 | | - | |
109 | | - | |
| 104 | + | |
| 105 | + | |
110 | 106 | | |
111 | 107 | | |
112 | 108 | | |
| |||
131 | 127 | | |
132 | 128 | | |
133 | 129 | | |
134 | | - | |
| 130 | + | |
135 | 131 | | |
136 | 132 | | |
137 | 133 | | |
| |||
150 | 146 | | |
151 | 147 | | |
152 | 148 | | |
153 | | - | |
| 149 | + | |
154 | 150 | | |
155 | 151 | | |
156 | 152 | | |
| |||
159 | 155 | | |
160 | 156 | | |
161 | 157 | | |
162 | | - | |
| 158 | + | |
| 159 | + | |
163 | 160 | | |
164 | | - | |
165 | | - | |
166 | | - | |
167 | | - | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
168 | 165 | | |
169 | 166 | | |
170 | 167 | | |
171 | | - | |
172 | | - | |
173 | | - | |
174 | | - | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
175 | 172 | | |
176 | 173 | | |
177 | 174 | | |
| |||
187 | 184 | | |
188 | 185 | | |
189 | 186 | | |
190 | | - | |
191 | | - | |
| 187 | + | |
| 188 | + | |
192 | 189 | | |
193 | 190 | | |
194 | 191 | | |
| |||
Lines changed: 2 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
156 | 156 | | |
157 | 157 | | |
158 | 158 | | |
159 | | - | |
| 159 | + | |
160 | 160 | | |
161 | 161 | | |
162 | 162 | | |
163 | 163 | | |
164 | 164 | | |
165 | | - | |
| 165 | + | |
166 | 166 | | |
167 | 167 | | |
168 | 168 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
7 | | - | |
8 | 6 | | |
9 | 7 | | |
10 | 8 | | |
| |||
29 | 27 | | |
30 | 28 | | |
31 | 29 | | |
32 | | - | |
| 30 | + | |
33 | 31 | | |
34 | 32 | | |
35 | 33 | | |
| |||
97 | 95 | | |
98 | 96 | | |
99 | 97 | | |
100 | | - | |
| 98 | + | |
101 | 99 | | |
102 | 100 | | |
103 | 101 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
30 | | - | |
| 30 | + | |
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
| |||
0 commit comments