Appearance
Type engine
Everything under packages/core/src/engine/** is type-level code that runs in the TypeScript checker. These conventions are the result of measurements on TypeScript 7.0 (tsc, the native compiler). Read this before touching the engine.
Measured limits (TS 7.0.2)
| Limit | Value | Consequence |
|---|---|---|
| Tail-recursive conditional type iterations | 1000 | Any loop over tokens/chars must be chunked or guaranteed short. The lexer restarts every 250 tokens (LexChunk). |
| Nesting depth of non-tail alias calls | ≈ 90+ levels verified | Recursive descent is fine. Do not flatten tuples with [H, ...Rec<R>]; use accumulators. |
| Instantiations per top-level statement | 5 000 000 | Per statement (a type X = … declaration), not per file. 500 queries in one file compiled fine. |
| Union size | ~100 000 | Never build ${Digit}${Digit}… templates; count with a loop (DigitCount). |
Tuple arithmetic (Tup<N>) | 1000 | Only for small numbers (param indexes, precedence). Never for string offsets. |
Pipeline
Lex<Q> → Toks engine/lexer
ParseProgram<Toks> → Stmt engine/parser (recursive descent + Pratt)
ProjectStmt<Stmt, DB, Outer, Ctes> → Cols | null engine/project (+ binder, typer)
ParamsOfStmt<Stmt, DB> → ParamMap engine/typer/params
Analyze<DB, Q> → { kind, cols, params, max, stmt } | SqlError engine/infer
RowOf / ParamsOf / Validate public projections of AnalyzeResult conventions
- Tokens are accessed by index, never sliced. A parse function is
P<T extends Toks, I extends number> = Ok<Node, J> | SqlErrorwhereTis the whole token tuple,Ithe current index andJthe index after the node.Ok<N, J>isreadonly [N, J].T[I]isundefinedpast the end.Nx<I>(= I + 1) is a lookup in the generatedNexttable (lexer/next.ts, regenerate withnode scripts/gen-next.mjs). Propagate with:tsWhy: matchingParseX<T, I> extends infer R ? R extends Ok<infer N, infer J extends number> ? … : R : neverreadonly [infer H, ...infer R]builds a new tuple of length n−1 each time (O(n)); a parser doing ~10 such matches per token is O(n²). Measured on 160-column statements: 10.2M instantiations with slicing vs 1.07M index-based. - Errors are
SqlError<Message, At>. Parser errors use the token index. Semantic errors (binder/typer/projector) usenull;Analyzelocates the identifier named in the message in the token stream. - Semantic stages return
ColInfo({ type, nullable }),Cols,ColMap, orSqlError. Test withX extends ColInfo ? … : Xto propagate.
Pitfalls (each one cost real debugging time)
neverinextends [infer A, infer B]takes the TRUE branch and bindsA/Bto their constraints. Helpers that can fail must returnnull(or anSqlError), nevernever.Ident,QualName,ColIdIdent,ParseJoinKindreturnnull.EatWord/EatOpreturnneverbut are only ever tested withextends never.- A union-constrained
inferin a template literal matches ONE character.S extends `${infer O extends "::" | "->"}${infer R}`never matches::. Build candidates explicitly (seeLexOpin the lexer). - A union in a template-literal delimiter position yields a union of all splits, not the shortest. Scan words char by char with
${infer C}${infer R}+C extends IdChar. - Conditional type narrowing does not satisfy structural constraints. In
B extends { t: "simple" } ? F<B>,BisB & { t: "simple" }, which TS will not accept whereSimpleSelectis required. UseF<Extract<B, SimpleSelect>>. - Deferred alias references inside tuples/objects are not evaluated.
[Rec<R>]looks cheap but hides unbounded recursion until something forces it. Prefer accumulators (Rec<R, [...Acc, X]>), which are also tail calls. - Keep AST nodes free of optional properties. Use
nullfor absence; withexactOptionalPropertyTypesoptional props make pattern matching brittle. - Lexer dispatch order matters for cost. Infer the first char once, then branch on its class (whitespace, identifier start, digit, quote,
$, operator). - Parameters are typed in a separate pass (
ParamsOfExpr) that looks at the sibling of a$nnode. The main typer treats$nasunknownso the other side wins. - Never pass a recursive conditional result into a constrained type parameter.
F<ParamsOfExpr<…>>withF<M extends ParamMap>forces TS to compute the constraint of the whole mutually recursive alias graph at declaration time ("excessively deep", or minutes of compile time). Accumulator parameters are unconstrained; useinfer X extends …to check shapes instead. - Nested
Nx<Nx<I>>is fine only becauseNexthas a numeric index signature. A conditional (I extends keyof Next ? …) in that position made every nested use a 4096-member union comparison at declaration time. - AST constraint types must be shallow. Comparing a concrete AST subtree against a recursive union (
infer L extends ExprwithExpr= the precise node union) walks the whole subtree; a left-nesteda OR b OR …of ~50 terms hit "Excessive stack depth comparing types". Generic-interface nodes do not help in TS 7.Expris therefore{ readonly t: ExprTag; readonly [field: string]: any }(the precise union isExprNode): checks look at one level, andE["l"]on a narrowed node isany, which satisfies any constraint. 200-termORchains now type-check. Keep other recursive AST types (FromItem,SelectBody) shallow in practice: joins and set operations rarely nest deeply. - Evolved types must not reference their previous versions.
ApplyDdlfirst built each new schema asOmit<Prev, K> & { … }, so a schema after N statements held a chain of N earlier schemas, and every conditional that inspected it walked the chain ("excessively deep" after ~45 statements, also breaking queries against that schema).ddl/apply.tsrebuilds every stored table and namespace from single-key pieces (Build), so each version stands alone. - Don't round-trip an index cursor through a tuple. Storing
Iin a tuple and reading it back withinfer I extends numberkeeps the unevaluatedNx<Nx<…>>chain and hits the instantiation-depth limit. Pass indexes as direct type arguments. - Tail-recursive walks that push AST subtrees onto a tuple stack are quadratic. Plain recursion over indexed access, iterating only along the left spine, is linear.
- The parser balances
AND/ORchains.a OR b OR c …becomes a balanced tree (depth log n), so a 200-term chain costs ~60K instantiations instead of 3.4M. Other left-associative operators (||,+) are not balanced (types may depend on order); chains of ~100 such terms are the practical limit. - Error carrier properties must not shadow
String.prototypemembers. The call site types the query asSqlError<…> & Q; a property namedat(orlength,slice, …) conflicts with the string's apparent type and TypeScript reduces the intersection tonever, which hides the message.SqlErrorusesposition. - Build object types with intersections, flatten once.
ColsToMapaccumulatesAcc & { [K in N]: T }and maps overkeyof Accat the end; chaining mapped types (MergeMap<MergeMap<…>>) made every member lookup resolve the whole chain.
Adding grammar
- New expression syntax: add an AST node in
ast.ts, parse it inparser/expr.ts(prefix or infix), type it intyper/typeof.ts, name it inproject/naming.ts, and handle params intyper/params.ts. Add tests intest/types/<feature>.test-d.ts. - New function: add a
SigtoFunctionCatalogintyper/catalog.ts, then regeneratetyper/pg-functions.ts(the uncurated pg_catalog rest) withTYPESQL_PG_URL=… node scripts/gen-pg-functions.mjs. - New statement: add to
ast.ts, parse inparser/<kind>.ts, dispatch inparser/stmt.ts, project inproject/stmt.ts.
Testing
pnpm test:typesruns vitest in typecheck mode overtest/types/*.test-d.ts. UseexpectTypeOf<Row<"…">>().toEqualTypeOf<{…}>()and// @ts-expect-error.- Quick iteration:
./node_modules/.bin/tsc --noEmit --strict --target es2024 --moduleResolution bundler --module esnext --skipLibCheck <file>on a scratch file underscratchpad/. Add--extendedDiagnosticsto see instantiation counts.