Core SQL primitives for TypeScript — typed sql tag, driver-agnostic adapters, and a compile-time SQL type system.
@playwright-labs/sql-core is the shared foundation used by @playwright-labs/fixture-sql. It can also be used standalone in any TypeScript project that needs:
- A
sqlfunction that validates SQL structure and infers parameter counts at compile time - A
SqlClient/SqlAdapterinterface that works with PostgreSQL, MySQL, and SQLite - Ready-made adapter implementations for all three databases
- Type utilities (
SQLParams,ValidSQL) that catch SQL mistakes before tests run
- Typed
sqlfunction — plain string and array forms returnSqlStatement<P>, which encodes the parameter count as a phantom brand; the compiler enforces the correct params array at every call site - Compile-time SQL validation — a TypeScript finite-state-machine models SQL grammar; structurally invalid SQL (missing
FROM, noSETinUPDATE, gaps in$Nsequences, …) producesneverat compile time - Driver-agnostic
SqlClient— one interface for PostgreSQL, MySQL, and SQLite with typed overloads for parameterised queries - Three ready-made adapters —
sqliteAdapter,pgAdapter,mysqlAdaptervia optional peer dependencies ?and$Nparameter styles — both MySQL/SQLite (?) and PostgreSQL ($1,$2, …) are supported;$Nparameters must be sequential (gap like$3without$2→never)- ESM + CJS dual build — works in both module systems
pnpm add @playwright-labs/sql-core
# install the driver you actually use
pnpm add -D better-sqlite3 # SQLite
pnpm add -D pg # PostgreSQL
pnpm add -D mysql2 # MySQL / MariaDBimport { sql } from '@playwright-labs/sql-core';
import { sqliteAdapter } from '@playwright-labs/sql-core/sqlite';
const client = await sqliteAdapter(':memory:').create();
await client.execute(sql`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`);
await client.execute(sql`INSERT INTO users VALUES (?, ?)`, [1, 'Alice']);
const { rows } = await client.query<{ name: string }>(
sql`SELECT name FROM users WHERE id = ?`,
[1],
);
console.log(rows[0]!.name); // 'Alice'
await client.close();The sql export works in three calling forms with different type inference behaviour:
| Form | TypeScript infers | Return type |
|---|---|---|
sql`SELECT …` |
TemplateStringsArray |
string |
sql("SELECT …") |
literal string S |
SqlStatement<P> |
sql(["SELECT …"]) |
literal tuple [S] |
SqlStatement<P> |
sql`SELECT ${val}` |
multi-part template | string |
TypeScript always infers TemplateStringsArray for tagged template strings arguments, which prevents literal-type inference. Use the plain string or array form when compile-time SQL validation is needed.
import { sql } from '@playwright-labs/sql-core';
// Plain string — captures literal type, validates SQL, infers params
const q1 = sql("SELECT * FROM users WHERE id = ?");
// typeof q1 → SqlStatement<[unknown]>
// Array form — identical inference via mutable literal tuple
const q2 = sql(["SELECT * FROM users WHERE id = ?"]);
// typeof q2 → SqlStatement<[unknown]>
// TypeScript enforces the params array:
await client.query(q1, [42]); // ✅ one param — ok
await client.query(q1); // ❌ compile error — params required
await client.query(q1, [1, 2]); // ❌ compile error — too many params
// Invalid SQL → never (compile error at the call site)
const bad = sql("SELECT * WHERE id = ?");
// ^^^ never — missing FROM// Returns string — no compile-time param checking
const q3 = sql`SELECT * FROM users WHERE id = ?`;
// typeof q3 → string
// Interpolations return string too
const table = 'users';
const q4 = sql`SELECT * FROM ${table} LIMIT 10`;
// typeof q4 → string (runtime concatenation)Use the tagged template form for syntax highlighting in editors that support it. For type safety, use sql("…") or sql(["…"]).
Resolves to a tuple of unknown whose length equals the number of parameters in S. Returns never if S is structurally invalid SQL or if $N parameters are not sequential.
import type { SQLParams } from '@playwright-labs/sql-core';
type A = SQLParams<'SELECT * FROM users WHERE id = ?'>;
// ^-- [unknown]
type B = SQLParams<'UPDATE t SET x = $1, y = $2 WHERE id = $3'>;
// ^-- [unknown, unknown, unknown]
type C = SQLParams<'SELECT *'>;
// ^-- never (missing FROM)
type D = SQLParams<'SELECT * FROM t WHERE id = $3'>;
// ^-- never ($1 and $2 are missing — gap in sequence)Validated statement forms:
| Statement | Required clauses |
|---|---|
SELECT |
FROM |
UPDATE |
table name, SET |
DELETE |
FROM, table name |
INSERT |
INTO, VALUES or sub-SELECT |
CREATE |
TABLE, table name |
Optional clauses (JOIN, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT, OFFSET) are accepted after the required ones.
S if the SQL is structurally valid, never otherwise.
import type { ValidSQL, SQLParams } from '@playwright-labs/sql-core';
function runQuery<S extends string>(sql: ValidSQL<S>, params: SQLParams<S>) { … }
runQuery('SELECT * FROM users', []); // ✅
runQuery('SELECT *', []); // ❌ compile error — missing FROMA branded string type that carries the parameter tuple P as a phantom type. Produced by sql("…") and sql(["…"]); consumed by the typed SqlClient overloads.
import type { SqlStatement } from '@playwright-labs/sql-core';
declare const stmt: SqlStatement<[unknown, unknown]>;
// assignable to string, but plain string is NOT assignable to SqlStatementAlias for SQLParams<S>.
import { sqliteAdapter } from '@playwright-labs/sql-core/sqlite';
const client = await sqliteAdapter(':memory:').create(); // in-memory
const client2 = await sqliteAdapter('./path/to/db.sqlite').create(); // filePeer dependency: better-sqlite3 >=9.0.0
import { pgAdapter } from '@playwright-labs/sql-core/pg';
const client = await pgAdapter('postgresql://user:pass@localhost:5432/mydb').create();
const client2 = await pgAdapter({ host: 'localhost', database: 'mydb' }).create();Peer dependency: pg >=8.0.0
import { mysqlAdapter } from '@playwright-labs/sql-core/mysql';
const client = await mysqlAdapter('mysql://user:pass@localhost:3306/mydb').create();
const client2 = await mysqlAdapter({ host: 'localhost', database: 'mydb' }).create();Peer dependency: mysql2 >=3.0.0
| Import path | Driver | Peer dependency |
|---|---|---|
@playwright-labs/sql-core/sqlite |
SQLite | better-sqlite3 >=9.0.0 |
@playwright-labs/sql-core/pg |
PostgreSQL | pg >=8.0.0 |
@playwright-labs/sql-core/mysql |
MySQL / MariaDB | mysql2 >=3.0.0 |
All peer dependencies are optional — install only the driver you use.
The package ships a pull CLI that introspects a live database and generates TypeScript row-type interfaces — the same db-types.ts file that @playwright-labs/ts-plugin-sql reads via its schemaFile option.
The CLI is exposed under two bin names: @playwright-labs/sql-core and playwright-labs-sql-core.
pnpm add -D @playwright-labs/sql-coreInstall it as a direct devDependency — pnpm only links bins of direct dependencies into node_modules/.bin. npm users can run npx playwright-labs-sql-core pull ... without an explicit install.
pnpm sql-core pull --adapter <sqlite|pg|mysql> --url <url> [--out <file>]
Flags: -a, --adapter, -u, --url, -o, --out. Without --out the types are printed to stdout.
pnpm sql-core pull --adapter sqlite --url ./dev.db --out ./src/db-types.ts
pnpm sql-core pull --adapter pg --url postgresql://user:pass@localhost/mydb --out ./src/db-types.ts
pnpm sql-core pull --adapter mysql --url mysql://user:pass@localhost/mydb --out ./src/db-types.tsThe generated file starts with a header and declares one interface per table:
// Auto-generated by @playwright-labs/sql-core
// Adapter : sqlite
// Source : ./dev.db
// Generated: 2026-01-01T00:00:00.000Z
// Do not edit this file manually — re-run `@playwright-labs/sql-core pull` to refresh.
/** Row type for the `users` table */
export interface UsersRow {
id: number;
name: string;
email: string | null;
}
/** All table row types, keyed by table name. */
export type Tables = {
users: UsersRow;
};// Tagged template — always returns string (no compile-time validation)
sql`SELECT * FROM users WHERE id = ?` // → string
// Plain string — validates SQL, infers param tuple
sql("SELECT * FROM users WHERE id = ?") // → SqlStatement<[unknown]>
// Array form — identical to plain string, useful when SQL is stored separately
sql(["SELECT * FROM users WHERE id = ?"]) // → SqlStatement<[unknown]>
sql(["SELECT * FROM ", " WHERE id = ?"]) // → string (multi-element = dynamic)interface SqlClient {
// Typed overloads — enforce params array via SqlStatement brand
query<T = Row, P extends readonly [unknown, ...unknown[]]>(
sql: SqlStatement<P>,
params: readonly [...P],
): Promise<QueryResult<T>>;
// Fallback — plain string or no-param statement
query<T = Row>(sql: string, params?: unknown[]): Promise<QueryResult<T>>;
execute<P extends readonly [unknown, ...unknown[]]>(
sql: SqlStatement<P>,
params: readonly [...P],
): Promise<void>;
execute(sql: string, params?: unknown[]): Promise<void>;
close(): Promise<void>;
}interface QueryResult<T> {
rows: T[];
rowCount: number;
command?: string; // e.g. "SELECT", "INSERT 0 1"
}interface SqlAdapter {
create(): Promise<SqlClient>;
}Implement SqlAdapter to add support for any other database driver:
import type { SqlAdapter, SqlClient, QueryResult, Row } from '@playwright-labs/sql-core';
export function myAdapter(url: string): SqlAdapter {
return {
async create(): Promise<SqlClient> {
const conn = await MyDriver.connect(url);
return {
async query<T = Row>(sql: string, params?: unknown[]): Promise<QueryResult<T>> {
const result = await conn.query(sql, params);
return { rows: result.rows as T[], rowCount: result.rowCount };
},
async execute(sql: string, params?: unknown[]): Promise<void> {
await conn.query(sql, params);
},
async close(): Promise<void> {
await conn.end();
},
} as SqlClient;
},
};
}MIT