Type-Level SQL SELECT Parser
šµ Vibe-coded with Claude Opus 4.5
š Inspired by and built upon telefrek/sql - a TypeScript SQL parsing series
A TypeScript type-level parser that transforms SQL SELECT query string literals into their corresponding AST types at compile time.
Features
- š„ Compile-time parsing: SQL queries are parsed entirely at the type level
- š Full AST representation: Returns a complete Abstract Syntax Tree as a type
- šÆ Type-safe results: Match queries against your schema to get result types
- ā
Query validation: Catch errors at compile time
- š« Zero runtime: Pure type-level operations, no runtime code
Supported SQL Features
| SELECT columns | ā
| SELECT id, name |
| SELECT * | ā
| SELECT * |
| Column aliases | ā
| SELECT id AS user_id |
| Table aliases | ā
| FROM users AS u |
| Table.* wildcard | ā
| SELECT u.* |
| DISTINCT | ā
| SELECT DISTINCT role |
| FROM clause | ā
| FROM users |
| WHERE clause | ā
| WHERE id = 1 |
| AND/OR operators | ā
| WHERE a = 1 AND b = 2 |
| Comparison operators | ā
| =, !=, <>, <, >, <=, >= |
| LIKE/ILIKE | ā
| WHERE name LIKE '%john%' |
| IS NULL/IS NOT NULL | ā
| WHERE deleted_at IS NULL |
| INNER JOIN | ā
| INNER JOIN orders ON... |
| LEFT JOIN | ā
| LEFT JOIN orders ON... |
| RIGHT JOIN | ā
| RIGHT JOIN orders ON... |
| FULL JOIN | ā
| FULL JOIN orders ON... |
| CROSS JOIN | ā
| CROSS JOIN categories |
| Multiple JOINs | ā
| Multiple JOIN clauses |
| ORDER BY | ā
| ORDER BY name DESC |
| GROUP BY | ā
| GROUP BY role |
| HAVING | ā
| HAVING COUNT(*) > 5 |
| LIMIT | ā
| LIMIT 10 |
| OFFSET | ā
| OFFSET 20 |
| COUNT | ā
| COUNT(*) |
| SUM/AVG/MIN/MAX | ā
| SUM(amount) |
| WITH (CTEs) | ā
| WITH cte AS (...) |
| Derived tables | ā
| FROM (SELECT...) AS sub |
| Scalar subqueries | ā
| SELECT (SELECT...) |
| Type casting | ā
| id::text |
| JSON operators | ā
| data->>'key' |
| Quoted identifiers | ā
| "firstName", "user-id" |
| camelCase (quoted) | ā
| "userAccounts"."firstName" |
| Schema prefix | ā
| FROM public.users |
| Cross-schema JOINs | ā
| JOIN audit.logs ON... |
Installation
npm install @kuindji/sql-type-parser
This is a pure TypeScript type library - just import the types:
import type {
ParseSQL,
QueryResult,
ValidateSQL,
} from "@kuindji/sql-type-parser";
Quick Start
1. Define Your Schema
type MySchema = {
defaultSchema: "public";
schemas: {
public: {
users: {
id: number;
name: string;
email: string;
role: "admin" | "user";
created_at: string;
};
orders: {
id: number;
user_id: number;
total: number;
status: "pending" | "completed";
};
};
};
};
2. Get Typed Query Results
import type { QueryResult } from "@kuindji/sql-type-parser";
type UserNames = QueryResult<"SELECT id, name FROM users", MySchema>;
type OrdersWithUsers = QueryResult<
`
SELECT u.name, o.total, o.status
FROM users AS u
INNER JOIN orders AS o ON u.id = o.user_id
`,
MySchema
>;
type Roles = QueryResult<"SELECT role FROM users", MySchema>;
3. Validate Queries at Compile Time
import type { ValidateSQL } from "@kuindji/sql-type-parser";
type IsValid = ValidateSQL<"SELECT id FROM users", MySchema>;
type HasError = ValidateSQL<"SELECT unknown_col FROM users", MySchema>;
type TableError = ValidateSQL<"SELECT * FROM bad_table", MySchema>;
Detailed Usage
Basic Queries
type All = QueryResult<"SELECT * FROM users", MySchema>;
type Specific = QueryResult<"SELECT id, email FROM users", MySchema>;
type Aliased = QueryResult<
"SELECT id AS user_id, name AS display_name FROM users",
MySchema
>;
type TableAliased = QueryResult<
"SELECT u.id, u.name FROM users AS u",
MySchema
>;
type Distinct = QueryResult<"SELECT DISTINCT role FROM users", MySchema>;
JOINs
type Inner = QueryResult<
`
SELECT u.name, o.total
FROM users AS u
INNER JOIN orders AS o ON u.id = o.user_id
`,
MySchema
>;
type Left = QueryResult<
`
SELECT u.name, o.total
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
`,
MySchema
>;
type Multi = QueryResult<
`
SELECT u.name, o.total, p.name AS product
FROM users AS u
INNER JOIN orders AS o ON u.id = o.user_id
INNER JOIN order_items AS oi ON o.id = oi.order_id
INNER JOIN products AS p ON oi.product_id = p.id
`,
MySchema
>;
Schema-Qualified Queries
When your database has multiple schemas, you can use schema-qualified identifiers:
type MultiSchema = {
defaultSchema: "public";
schemas: {
public: {
users: { id: number; name: string; email: string; };
posts: { id: number; user_id: number; title: string; };
};
audit: {
logs: {
id: number;
user_id: number;
action: string;
created_at: string;
};
};
analytics: {
events: { id: number; event_type: string; user_id: number; };
};
};
};
type DefaultSchema = QueryResult<"SELECT id, name FROM users", MultiSchema>;
type ExplicitSchema = QueryResult<
"SELECT id, email FROM public.users",
MultiSchema
>;
type AuditSchema = QueryResult<
"SELECT id, action, created_at FROM audit.logs",
MultiSchema
>;
type CrossSchemaJoin = QueryResult<
`
SELECT u.name, al.action, al.created_at
FROM public.users AS u
INNER JOIN audit.logs AS al ON u.id = al.user_id
`,
MultiSchema
>;
type SchemaColumn = QueryResult<
"SELECT public.users.name, audit.logs.action FROM public.users JOIN audit.logs ON public.users.id = audit.logs.user_id",
MultiSchema
>;
type SchemaWildcard = QueryResult<
"SELECT public.users.* FROM public.users",
MultiSchema
>;
Aggregates
type Count = QueryResult<"SELECT COUNT ( * ) AS total FROM users", MySchema>;
type Sum = QueryResult<"SELECT SUM ( total ) AS revenue FROM orders", MySchema>;
type Grouped = QueryResult<
`
SELECT user_id, COUNT ( * ) AS order_count, SUM ( total ) AS total_spent
FROM orders
GROUP BY user_id
`,
MySchema
>;
Common Table Expressions (CTEs)
type WithCTE = QueryResult<
`
WITH active_users AS (
SELECT id, name FROM users WHERE status = 'active'
)
SELECT * FROM active_users
`,
MySchema
>;
type MultipleCTEs = QueryResult<
`
WITH
active_users AS (
SELECT id, name FROM users WHERE status = 'active'
),
order_totals AS (
SELECT user_id, SUM ( total ) AS total FROM orders GROUP BY user_id
)
SELECT au.name, ot.total
FROM active_users AS au
LEFT JOIN order_totals AS ot ON au.id = ot.user_id
`,
MySchema
>;
Derived Tables (Subqueries in FROM)
type DerivedTable = QueryResult<
`
SELECT sub.user_name, sub.order_count
FROM (
SELECT u.name AS user_name, COUNT ( o.id ) AS order_count
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
GROUP BY u.name
) AS sub
WHERE sub.order_count > 5
`,
MySchema
>;
PostgreSQL Type Casting
type CastText = QueryResult<"SELECT id::text AS id_str FROM users", MySchema>;
type CastInt = QueryResult<
"SELECT amount::integer AS int_amount FROM data",
MySchema
>;
type JsonCast = QueryResult<
"SELECT data->>'value'::integer AS val FROM docs",
MySchema
>;
JSON Operators (PostgreSQL)
type JsonAccess = QueryResult<
`
SELECT
data->>'name' AS name,
metadata#>>'{user,email}' AS email
FROM documents
`,
JsonSchema
>;
Identifier Case Handling
In SQL, unquoted identifiers are case-insensitive and typically lowercased by the database. To preserve case (camelCase, PascalCase, Mixed_Case), identifiers must be quoted.
type MySchema = {
defaultSchema: "public";
schemas: {
public: {
userAccounts: {
id: number;
firstName: string;
lastName: string;
emailAddress: string;
createdAt: string;
Account_Status: "active" | "suspended";
};
OrderItems: {
id: number;
orderId: number;
unitPrice: number;
Item_Status: "pending" | "shipped";
};
};
};
};
type Wrong = QueryResult<"SELECT firstName FROM userAccounts", MySchema>;
type Correct = QueryResult<'SELECT "firstName" FROM "userAccounts"', MySchema>;
camelCase Columns and Tables
type CamelCase = QueryResult<
'SELECT "firstName", "lastName", "emailAddress" FROM "userAccounts"',
MySchema
>;
type WithAlias = QueryResult<
'SELECT ua."firstName", ua."lastName" FROM "userAccounts" AS ua',
MySchema
>;
Mixed_Case Identifiers
type MixedCase = QueryResult<
'SELECT "Account_Status" FROM "userAccounts"',
MySchema
>;
JOINs with Quoted Identifiers
type QuotedJoin = QueryResult<
`
SELECT ua."firstName", oi."unitPrice", oi."Item_Status"
FROM "userAccounts" AS ua
INNER JOIN "OrderItems" AS oi ON ua.id = oi."orderId"
`,
MySchema
>;
Quoted Aliases
type AliasCase = QueryResult<
'SELECT "firstName" AS "FirstName", "lastName" AS last_name FROM "userAccounts"',
MySchema
>;
type SpacedAlias = QueryResult<
'SELECT "firstName" AS "First Name" FROM "userAccounts"',
MySchema
>;
Special Characters in Identifiers
type HyphenSchema = {
defaultSchema: "public";
schemas: {
public: {
"user-sessions": {
id: number;
"ip-address": string;
"user-agent": string | null;
};
};
};
};
type Hyphens = QueryResult<
'SELECT id, "ip-address", "user-agent" FROM "user-sessions"',
HyphenSchema
>;
Type Mappings
SQL to TypeScript Type Mapping
text, varchar, char | string |
int, integer, bigint, smallint, serial | number |
float, real, double precision, numeric, decimal | number |
bool, boolean | boolean |
json, jsonb | object |
date, timestamp, timestamptz, time | string |
uuid | string |
bytea | Uint8Array |
Aggregate Result Types
COUNT(*) | number |
COUNT(column) | number |
SUM(column) | number |
AVG(column) | number |
MIN(column) | Same as column type |
MAX(column) | Same as column type |
API Reference
Main Types
ParseSQL<T>
Parse a SQL string into an AST type.
type AST = ParseSQL<"SELECT * FROM users">;
QueryResult<SQL, Schema>
Parse SQL and match against schema to get result type.
type Result = QueryResult<"SELECT id, name FROM users", MySchema>;
ValidateSQL<SQL, Schema>
Validate a query at compile time. Returns true if valid, or error message string if invalid.
type Valid = ValidateSQL<"SELECT id FROM users", MySchema>;
type Invalid = ValidateSQL<"SELECT bad_col FROM users", MySchema>;
MatchQuery<AST, Schema>
Match a parsed AST against a schema (lower level than QueryResult).
type AST = ParseSQL<"SELECT id FROM users">;
type Result = MatchQuery<AST, MySchema>;
MatchError<Message>
Error type returned when column/table resolution fails.
type Error = MatchError<"Column 'x' not found">;
DatabaseSchema
Expected structure of a database schema with schema support.
type Schema = {
defaultSchema?: string;
schemas: {
[schemaName: string]: {
[tableName: string]: {
[columnName: string]: any;
};
};
};
};
AST Types
SQLQuery - Top-level query wrapper
SelectClause - SELECT statement AST
ColumnRef, TableColumnRef, UnboundColumnRef - Column references
TableRef, DerivedTableRef - Table references
CTEDefinition - Common Table Expression
JoinClause, JoinType - JOIN clause types
WhereExpr, BinaryExpr, LogicalExpr - Expression types
OrderByItem, SortDirection - ORDER BY types
AggregateExpr, AggregateFunc - Aggregate function types
LiteralValue - Literal value wrapper
ComplexExpr - Complex expressions (JSON ops, function calls)
SubqueryExpr - Scalar subqueries
Utility Types
ParseError - Error marker type
NormalizeSQL - SQL normalization type
NextToken, ExtractUntil, SplitByComma - Tokenization helpers
RemoveQuotes, Trim, Flatten - String/type utilities
Limitations
Due to TypeScript's type system constraints:
-
Recursion depth: Very complex queries with many columns, multiple JOINs, or deeply nested expressions may hit TypeScript's type instantiation depth limit (~50 levels)
-
Aggregate syntax: Aggregate functions require spaces around parentheses:
- ā
COUNT ( * )
- ā
COUNT(*)
-
Complex WHERE: WHERE expressions are parsed but not fully typed (used for validation markers only, not type inference)
-
Subquery depth: Deeply nested subqueries may exceed recursion limits
-
Expression arithmetic: Mathematical expressions in SELECT aren't evaluated for type
-
Quoted identifiers with spaces: Identifiers containing spaces (e.g., "my table", "user id") are not supported because the tokenizer splits on spaces. Use hyphens or underscores instead:
- ā
"user-id" or "user_id"
- ā
"user id"
File Structure
parser/type-parser/
āāā src/
ā āāā index.ts # Public API - re-exports all types
ā āāā parser.ts # Main parser types
ā āāā matcher.ts # Schema matcher types
ā āāā tokenizer.ts # SQL tokenization utilities
ā āāā ast.ts # AST type definitions
ā āāā utils.ts # String and number utilities
ā āāā examples/
ā āāā index.ts # Examples entry point
ā āāā schema.ts # Example database schemas
ā āāā parser-examples.ts # Parser feature examples
ā āāā matcher-examples.ts # Type inference examples
ā āāā tests.ts # Type-level tests
āāā tsconfig.json # TypeScript configuration
āāā package.json # Package configuration
āāā README.md # This file
Examples
E-Commerce Query
import type { QueryResult } from "@kuindji/sql-type-parser";
import type { ECommerceSchema } from "@kuindji/sql-type-parser/examples";
type CustomerOrderSummary = QueryResult<
`
WITH order_stats AS (
SELECT
user_id,
COUNT ( * ) AS order_count,
SUM ( total_amount ) AS lifetime_value,
MAX ( created_at ) AS last_order
FROM orders
WHERE status != 'cancelled'
GROUP BY user_id
)
SELECT
u.email,
u.first_name,
u.last_name,
os.order_count,
os.lifetime_value,
os.last_order
FROM users AS u
LEFT JOIN order_stats AS os ON u.id = os.user_id
WHERE u.role = 'customer'
ORDER BY os.lifetime_value DESC
LIMIT 100
`,
ECommerceSchema
>;
Blog Query
import type { QueryResult } from "@kuindji/sql-type-parser";
import type { BlogSchema } from "@kuindji/sql-type-parser/examples";
type PostsWithComments = QueryResult<
`
SELECT
p.title,
p.status,
u.name AS author_name,
COUNT ( c.id ) AS comment_count
FROM posts AS p
INNER JOIN users AS u ON p.author_id = u.id
LEFT JOIN comments AS c ON p.id = c.post_id
WHERE p.status = 'published'
GROUP BY p.id, p.title, p.status, u.name
ORDER BY comment_count DESC
LIMIT 10
`,
BlogSchema
>;
Database Integration
This library provides factory functions to create type-safe query wrappers. Write your query once, get full type inference and validation.
Quick Start
import { createSelectFn } from "@kuindji/sql-type-parser";
type Schema = {
defaultSchema: "public";
schemas: {
public: {
users: {
id: number;
name: string;
email: string;
role: "admin" | "user";
};
};
};
};
const select = createSelectFn<Schema>((sql, params) => db.query(sql, params));
const users = await select("SELECT id, name FROM users WHERE role = $1", [
"admin",
]);
const bad = await select("SELECT unknown FROM users");
Factory Functions
createSelectFn
import { createSelectFn } from "@kuindji/sql-type-parser";
const select = createSelectFn<Schema>((sql, params) => db.query(sql, params));
const users = await select(
"SELECT id, name, email FROM users WHERE is_active = $1",
[ true ],
);
const roles = await select("SELECT role FROM users");
Create Your Own Wrapper
Use the ValidQuery type to build custom wrappers:
import type {
DatabaseSchema,
SelectResultArray,
ValidQuery,
} from "@kuindji/sql-type-parser";
function createMyWrapper<Schema extends DatabaseSchema>(
handler: (
sql: string,
params?: unknown[],
) => Promise<{ rows: unknown[]; count: number; }>,
) {
return function query<Q extends string>(
sql: ValidQuery<Q, Schema>,
params?: unknown[],
): Promise<{ rows: SelectResultArray<Q, Schema>; count: number; }> {
return handler(sql, params) as Promise<{
rows: SelectResultArray<Q, Schema>;
count: number;
}>;
};
}
const mySelect = createMyWrapper<Schema>((sql, params) =>
customDb.query(sql, params)
);
const result = await mySelect("SELECT id, name FROM users");
Compile-Time Validation
import type { IsValidSelect, ValidateSQL } from "@kuindji/sql-type-parser";
type Valid = ValidateSQL<"SELECT id FROM users", Schema>;
type Invalid = ValidateSQL<"SELECT bad FROM users", Schema>;
type IsValid = IsValidSelect<"SELECT id FROM users", Schema>;
import type {
ExtractParams,
HasParameters,
MaxParamNumber,
} from "@kuindji/sql-type-parser";
type Params = ExtractParams<"SELECT * FROM users WHERE id = $1 AND name = $2">;
type Max = MaxParamNumber<"SELECT * FROM users WHERE id = $1 AND x = $3">;
type HasParams = HasParameters<"SELECT * FROM users WHERE id = $1">;
Running Tests
The tests are type-level assertions. If the project compiles, all tests pass:
npm run typecheck
npx tsc --noEmit
Contributing
Contributions are welcome! Areas that could use improvement:
- Better error messages
- Performance optimizations for complex queries
- Additional aggregate functions
- Window functions support
License
MIT