{"slug": "i-built-a-type-safe-sql-library-for-bun-no-orm-no-codegen-just-sql-using-claude", "title": "I built a type-safe SQL library for Bun — no ORM, no codegen, just SQL (using Claude Code)", "summary": "Squn**, a lightweight, type-safe SQL query library built specifically for Bun that avoids ORMs, code generation, or schema files. It uses tagged template literals to ensure all interpolated values become bound parameters, making SQL injection structurally impossible, and supports PostgreSQL, SQLite, MySQL, and MSSQL through swappable adapters. The library also provides features like composable query fragments, automatic transaction management, batch inserts, and optional table schema definitions for inferred TypeScript types.", "body_md": "I've been using Bun for a while and kept running into the same problem: every SQL library either requires Node.js internals, leans heavily on an ORM abstraction I don't want, or generates types from a schema file at build time.\n\nSo I built **squn** — a lightweight, type-safe SQL query library that works natively with Bun's built-in database clients.\n\n## The core idea\n\nEvery query goes through a tagged template literal called `sql`\n\n. Interpolated values always become bound parameters — they are never concatenated into the SQL string. SQL injection is structurally impossible by design.\n\n``` js\nimport { createDb, PostgresAdapter, sql } from \"@phonemyatt/squn\";\n\nconst db = createDb(new PostgresAdapter({\n  url: \"postgresql://user:password@localhost:5432/mydb\",\n}));\n\ninterface User {\n  id: number;\n  name: string;\n  age: number | null;\n}\n\n// Values become $1, $2 parameters — never string-concatenated\nconst users = await db.query<User>(sql`SELECT * FROM users WHERE age > ${18}`);\n```\n\nNo schema file. No code generation step. No build-time magic. You write SQL, get back typed results.\n\n## Four databases, one API\n\nsqun supports all four databases you're likely to use with Bun:\n\n| Database | Driver |\n|---|---|\n| SQLite |\n`bun:sqlite` (built-in) |\n| PostgreSQL | Bun's native Postgres |\n| MySQL | Bun's native MySQL |\n| MSSQL |\n`mssql` npm package |\n\nThe same query code works across all four — only the adapter construction changes.\n\n``` js\n// Switch databases by swapping the adapter\nconst db = createDb(new SqliteAdapter({ filename: \":memory:\" }));\nconst db = createDb(new PostgresAdapter({ url: process.env.PG_URL }));\nconst db = createDb(new MysqlAdapter({ url: process.env.MYSQL_URL }));\nconst db = createDb(new MssqlAdapter({ host: \"localhost\", ... }));\n```\n\n## Query methods that match what you actually need\n\n``` js\n// All rows\nconst users = await db.query<User>(sql`SELECT * FROM users`);\n\n// First row or null — no throw\nconst user = await db.queryFirst<User>(sql`SELECT * FROM users WHERE id = ${1}`);\n\n// Exactly one row — throws if 0 or 2+ rows returned\nconst user = await db.querySingle<User>(sql`SELECT * FROM users WHERE id = ${1}`);\n\n// Scalar — first column of first row\nconst count = await db.queryScalar<number>(sql`SELECT COUNT(*) FROM users`);\n```\n\n## Composable SQL fragments\n\nFragments compose. Nested fragments merge inline and placeholders are renumbered automatically.\n\n``` js\nconst minAge = 18;\nconst activeOnly = true;\n\nconst conditions = [\n  sqlIf(minAge !== undefined, sql`age >= ${minAge}`),\n  sqlIf(activeOnly, sql`active = ${true}`),\n];\n\nconst where = sqlJoin(conditions, \" AND \");\nconst q = sql`SELECT * FROM users WHERE ${where} ORDER BY name`;\n// → SELECT * FROM users WHERE age >= $1 AND active = $2 ORDER BY name\n// params → [18, true]\n```\n\nNo string concatenation. No injection risk. Full composability.\n\n## Transactions that don't leak\n\n`atomically`\n\nwraps your callback in BEGIN/COMMIT and rolls back automatically on error:\n\n``` js\nawait db.atomically(async (q) => {\n  await q.execute(sql`UPDATE accounts SET balance = balance - ${100} WHERE id = ${from}`);\n  await q.execute(sql`UPDATE accounts SET balance = balance + ${100} WHERE id = ${to}`);\n  // if either throws, both updates are rolled back\n});\n```\n\n`Transaction`\n\nalso implements `Symbol.asyncDispose`\n\n— so `await using`\n\ngives you guaranteed cleanup:\n\n```\nawait using tx = new Transaction(await adapter.beginTransaction());\nawait tx.execute(sql`UPDATE users SET active = ${false} WHERE id = ${42}`);\nawait tx.commit();\n// if commit throws or you return early, rollback happens automatically\n```\n\n## Batch inserts with a single prepared statement\n\n```\nawait db.executeBatch(\n  sql`INSERT INTO users (name, age) VALUES (@name, @age)`,\n  [\n    { name: \"Alice\", age: 30 },\n    { name: \"Bob\",   age: 25 },\n    { name: \"Carol\", age: 35 },\n  ],\n);\n```\n\nOne prepared statement, all rows bound in a loop. Much faster than individual inserts.\n\n## Type inference from table definitions\n\nDefine your table schema once, get insert/select/update types inferred automatically:\n\n``` js\nimport { col, defineTable, InferSelect, InferInsert } from \"@phonemyatt/squn\";\n\nconst Users = defineTable({\n  id:   col(\"integer\").primaryKey().notNull(),\n  name: col(\"text\").notNull(),\n  age:  col(\"integer\").nullable(),\n});\n\ntype UserRow    = InferSelect<typeof Users>;  // { id: number; name: string; age: number | null }\ntype UserInsert = InferInsert<typeof Users>;  // { name: string; age?: number | null }\n```\n\n## Multi-connection and read replicas\n\n``` js\nconst db = createConnections({\n  connections: {\n    primary: new PostgresAdapter({ url: process.env.PRIMARY }),\n    replica: new PostgresAdapter({ url: process.env.REPLICA }),\n  },\n  default: \"primary\",\n});\n\n// Route reads to replica\nconst users = await db.query<User>(sql`SELECT * FROM users`, { connection: \"replica\" });\n\n// Scoped helper — no connection option needed per call\nconst replica = db.use(\"replica\");\n\n// Typed concurrent queries\nconst [users, roles] = await db.concurrent(\n  db.query<User>(sql`SELECT * FROM users`),\n  db.query<Role>(sql`SELECT * FROM roles`),\n);\n```\n\n## Try it\n\n```\nbun add @phonemyatt/squn\n```\n\n- GitHub:\n[https://github.com/phonemyatt/squn](https://github.com/phonemyatt/squn) - npm:\n[https://www.npmjs.com/package/@phonemyatt/squn](https://www.npmjs.com/package/@phonemyatt/squn) - Docs:\n[https://phonemyatt.github.io/squn](https://phonemyatt.github.io/squn)\n\nFeedback welcome — especially from anyone using it with MySQL or MSSQL in production.\n\n*Built with TypeScript 5.9 strict mode, zero any, and tested against real databases in Docker.*", "url": "https://wpnews.pro/news/i-built-a-type-safe-sql-library-for-bun-no-orm-no-codegen-just-sql-using-claude", "canonical_source": "https://dev.to/phonemyatt/-i-built-a-type-safe-sql-library-for-bun-no-orm-no-codegen-just-sql-1k43", "published_at": "2026-05-22 09:26:43+00:00", "updated_at": "2026-05-22 09:46:52.213876+00:00", "lang": "en", "topics": ["developer-tools", "open-source", "data"], "entities": ["Bun", "squn", "PostgresAdapter", "SqliteAdapter", "MysqlAdapter", "MssqlAdapter", "Claude Code"], "alternates": {"html": "https://wpnews.pro/news/i-built-a-type-safe-sql-library-for-bun-no-orm-no-codegen-just-sql-using-claude", "markdown": "https://wpnews.pro/news/i-built-a-type-safe-sql-library-for-bun-no-orm-no-codegen-just-sql-using-claude.md", "text": "https://wpnews.pro/news/i-built-a-type-safe-sql-library-for-bun-no-orm-no-codegen-just-sql-using-claude.txt", "jsonld": "https://wpnews.pro/news/i-built-a-type-safe-sql-library-for-bun-no-orm-no-codegen-just-sql-using-claude.jsonld"}}