Skip to content

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) ​

LimitValueConsequence
Tail-recursive conditional type iterations1000Any 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 verifiedRecursive descent is fine. Do not flatten tuples with [H, ...Rec<R>]; use accumulators.
Instantiations per top-level statement5 000 000Per statement (a type X = … declaration), not per file. 500 queries in one file compiled fine.
Union size~100 000Never build ${Digit}${Digit}… templates; count with a loop (DigitCount).
Tuple arithmetic (Tup<N>)1000Only 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 Analyze

Result conventions ​

  • Tokens are accessed by index, never sliced. A parse function is P<T extends Toks, I extends number> = Ok<Node, J> | SqlError where T is the whole token tuple, I the current index and J the index after the node. Ok<N, J> is readonly [N, J]. T[I] is undefined past the end. Nx<I> (= I + 1) is a lookup in the generated Next table (lexer/next.ts, regenerate with node scripts/gen-next.mjs). Propagate with:
    ts
    ParseX<T, I> extends infer R ? R extends Ok<infer N, infer J extends number> ? … : R : never
    Why: matching readonly [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) use null; Analyze locates the identifier named in the message in the token stream.
  • Semantic stages return ColInfo ({ type, nullable }), Cols, ColMap, or SqlError. Test with X extends ColInfo ? … : X to propagate.

Pitfalls (each one cost real debugging time) ​

  1. never in extends [infer A, infer B] takes the TRUE branch and binds A/B to their constraints. Helpers that can fail must return null (or an SqlError), never never. Ident, QualName, ColIdIdent, ParseJoinKind return null. EatWord/EatOp return never but are only ever tested with extends never.
  2. A union-constrained infer in a template literal matches ONE character.S extends `${infer O extends "::" | "->"}${infer R}` never matches ::. Build candidates explicitly (see LexOp in the lexer).
  3. 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.
  4. Conditional type narrowing does not satisfy structural constraints. In B extends { t: "simple" } ? F<B>, B is B & { t: "simple" }, which TS will not accept where SimpleSelect is required. Use F<Extract<B, SimpleSelect>>.
  5. 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.
  6. Keep AST nodes free of optional properties. Use null for absence; with exactOptionalPropertyTypes optional props make pattern matching brittle.
  7. Lexer dispatch order matters for cost. Infer the first char once, then branch on its class (whitespace, identifier start, digit, quote, $, operator).
  8. Parameters are typed in a separate pass (ParamsOfExpr) that looks at the sibling of a $n node. The main typer treats $n as unknown so the other side wins.
  9. Never pass a recursive conditional result into a constrained type parameter.F<ParamsOfExpr<…>> with F<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; use infer X extends … to check shapes instead.
  10. Nested Nx<Nx<I>> is fine only because Next has 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.
  11. AST constraint types must be shallow. Comparing a concrete AST subtree against a recursive union (infer L extends Expr with Expr = the precise node union) walks the whole subtree; a left-nested a OR b OR … of ~50 terms hit "Excessive stack depth comparing types". Generic-interface nodes do not help in TS 7. Expr is therefore { readonly t: ExprTag; readonly [field: string]: any } (the precise union is ExprNode): checks look at one level, and E["l"] on a narrowed node is any, which satisfies any constraint. 200-term OR chains now type-check. Keep other recursive AST types (FromItem, SelectBody) shallow in practice: joins and set operations rarely nest deeply.
  12. Evolved types must not reference their previous versions. ApplyDdl first built each new schema as Omit<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.ts rebuilds every stored table and namespace from single-key pieces (Build), so each version stands alone.
  13. Don't round-trip an index cursor through a tuple. Storing I in a tuple and reading it back with infer I extends number keeps the unevaluated Nx<Nx<…>> chain and hits the instantiation-depth limit. Pass indexes as direct type arguments.
  14. 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.
  15. The parser balances AND/OR chains. 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.
  16. Error carrier properties must not shadow String.prototype members. The call site types the query as SqlError<…> & Q; a property named at (or length, slice, …) conflicts with the string's apparent type and TypeScript reduces the intersection to never, which hides the message. SqlError uses position.
  17. Build object types with intersections, flatten once. ColsToMap accumulates Acc & { [K in N]: T } and maps over keyof Acc at 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 in parser/expr.ts (prefix or infix), type it in typer/typeof.ts, name it in project/naming.ts, and handle params in typer/params.ts. Add tests in test/types/<feature>.test-d.ts.
  • New function: add a Sig to FunctionCatalog in typer/catalog.ts, then regenerate typer/pg-functions.ts (the uncurated pg_catalog rest) with TYPESQL_PG_URL=… node scripts/gen-pg-functions.mjs.
  • New statement: add to ast.ts, parse in parser/<kind>.ts, dispatch in parser/stmt.ts, project in project/stmt.ts.

Testing ​

  • pnpm test:types runs vitest in typecheck mode over test/types/*.test-d.ts. Use expectTypeOf<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 under scratchpad/. Add --extendedDiagnostics to see instantiation counts.