An open API service indexing awesome lists of open source software.

https://github.com/eikster-dk/sqlc-gen-better-typescript

sqlc.dev plugin that generates typescript code (and Effect v4)
https://github.com/eikster-dk/sqlc-gen-better-typescript

effect-ts postgresql sql sqlc

Last synced: 21 days ago
JSON representation

sqlc.dev plugin that generates typescript code (and Effect v4)

Awesome Lists containing this project

README

          

# sqlc-gen-better-typescript

A [sqlc](https://sqlc.dev) WASM plugin that generates type-safe TypeScript code from your SQL queries.

## Requirements

- [sqlc](https://sqlc.dev) v1.25.0 or later
- For the `effect-v4-unstable` builder:
- [Effect](https://effect.website) v4 (beta)
- TypeScript 5.5+

## What is this?

**sqlc-gen-better-typescript** is a flexible TypeScript code generator for sqlc that supports multiple output formats through a builder architecture. Instead of writing boilerplate database access code, you write SQL and the plugin generates fully typed TypeScript code tailored to your preferred libraries and patterns.

The current focus is on [Effect v4](https://effect.website) code generation, with planned support for:
- Native TypeScript (no external dependencies)
- Zod v4 schema validation
- Effect v3 compatibility

Depending on the builder you choose, you get:

- **Type-safe parameter schemas** using Effect's Schema library
- **Type-safe result schemas** with proper null handling via `Option`
- **Repository services** with Effect's dependency injection via `Layer`
- **Automatic SQL type mapping** to TypeScript/Effect types

### Effect v4 example

Write a SQL query:

```sql
-- name: GetCustomer :one
SELECT id, email, name, phone, created_at
FROM customers
WHERE id = $1;
```

Get a fully typed Effect repository:

```typescript
// Generated automatically
export const GetCustomerParams = Schema.Struct({
id: Schema.Int,
})

export type GetCustomerParams = typeof GetCustomerParams.Type

export const GetCustomerResult = Schema.Struct({
id: Schema.Int,
email: Schema.String,
name: Schema.String,
phone: Schema.OptionFromNullOr(Schema.String),
created_at: Schema.Date,
})

export type GetCustomerResult = typeof GetCustomerResult.Type

// Repository interface
export interface CustomersRepositoryShape {
readonly getCustomer: (params: GetCustomerParams) => Effect.Effect<
Option.Option,
SqlError.SqlError | Schema.SchemaError
>
}

// Service Tag
export class CustomersRepository extends ServiceMap.Service<
CustomersRepository,
CustomersRepositoryShape
>()("CustomersRepository") {}

// Implementation
const customersRepositoryImpl = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient

const getCustomer = SqlSchema.findOneOption({
Request: GetCustomerParams,
Result: GetCustomerResult,
execute: (params) => sql.unsafe(
`SELECT id, email, name, phone, created_at FROM customers WHERE id = $1`,
[params.id]
)
})

return { getCustomer } satisfies CustomersRepositoryShape
})

// Live Layer
export const customersRepositoryLive = Layer.effect(CustomersRepository, customersRepositoryImpl)

// Usage
const program = Effect.gen(function* () {
const repo = yield* CustomersRepository
const customer = yield* repo.getCustomer({ id: 1 })
// customer is Option.Option
})
```

### Nested Results with `sqlc.embed`

Use `sqlc.embed` to group columns from joined tables into nested structures. This is useful for queries that join multiple tables and you want the result to reflect that structure.

Write a SQL query with embeds:

```sql
-- name: GetOrderWithCustomer :one
SELECT sqlc.embed(orders), sqlc.embed(customers)
FROM orders
JOIN customers ON orders.customer_id = customers.id
WHERE orders.id = $1;
```

Get a nested result type:

```typescript
// Generated automatically - Row schema represents flat database result
const GetOrderWithCustomerRow = Schema.Struct({
orders_id: Schema.Int,
orders_customer_id: Schema.Int,
orders_status: OrderStatusSchema,
orders_total_cents: Schema.Int,
orders_shipping_address: Schema.NullOr(Schema.String),
// ... more orders columns
customers_id: Schema.Int,
customers_email: Schema.String,
customers_name: Schema.String,
// ... more customers columns
})

// Nested embed schemas with proper Option handling
const OrderEmbed = Schema.Struct({
id: Schema.Int,
customer_id: Schema.Int,
status: OrderStatusSchema, // Enums are preserved
total_cents: Schema.Int,
shipping_address: Schema.OptionFromNullOr(Schema.String), // Nullable -> Option
// ...
})

const CustomerEmbed = Schema.Struct({
id: Schema.Int,
email: Schema.String,
name: Schema.String,
// ...
})

// Result schema transforms flat rows to nested structure
export const GetOrderWithCustomerResult = GetOrderWithCustomerRow.pipe(
Schema.decodeTo(
Schema.Struct({
order: OrderEmbed, // Singularized table name
customer: CustomerEmbed,
}),
SchemaTransformation.transform({
decode: (row) => ({
order: {
id: row.orders_id,
customer_id: row.orders_customer_id,
status: row.orders_status,
shipping_address: row.orders_shipping_address,
// ...
},
customer: {
id: row.customers_id,
email: row.customers_email,
name: row.customers_name,
// ...
},
}),
encode: () => { throw new Error("Encode not supported for sqlc.embed queries"); },
})
)
)

// Result type is nested:
// {
// order: { id: number, status: "pending" | "shipped" | ..., shipping_address: Option, ... }
// customer: { id: number, email: string, name: string, ... }
// }
```

**Key features:**
- Table names are singularized for field names (`orders` → `order`, `customers` → `customer`)
- Columns are prefixed with table name to avoid conflicts (`orders_id`, `customers_id`)
- Enum types are preserved in embed schemas
- Nullable fields use `Schema.OptionFromNullOr` for consistent API with non-embed queries
- The transformation is decode-only (embed queries are read-only)

## Builders

The plugin uses a **builder** architecture to support different code generation targets. Each builder produces output tailored for a specific framework or library version.

## Available Builders

| Builder | Description | Status |
|---------|-------------|--------|
| `effect-v4-unstable` | Generates Effect v4 TypeScript code using `effect/unstable/sql` | Available |

### Effect v4 Builder

The `effect-v4-unstable` builder generates idiomatic Effect v4 code using the `effect/unstable/sql` module.

#### SQL Generation

By default, the plugin transforms sqlc's parameterized SQL into Effect's tagged template literal syntax:

```typescript
// Default output (template literals)
// GetCustomer
// SELECT * FROM customers WHERE id = $1 AND email = $2
execute: (params) => sql`SELECT * FROM customers WHERE id = ${params.id} AND email = ${params.email}`
```

The original SQL query is included as a comment above each query implementation for reference.

#### Preserving Original SQL

If you prefer to keep the sqlc-generated SQL statements unmodified, you can disable template literal transformation:

```yaml
options:
builder: effect-v4-unstable
disable_template_literals: true
```

This uses `sql.unsafe()` which passes the SQL exactly as sqlc generated it:

```typescript
// With disable_template_literals: true
// GetCustomer
// SELECT * FROM customers WHERE id = $1 AND email = $2
execute: (params) => sql.unsafe(
`SELECT * FROM customers WHERE id = $1 AND email = $2`,
[params.id, params.email]
)
```

Both approaches are equally safe from SQL injection. The choice is between:
- **Template literals (default)**: Cleaner syntax, but transforms the SQL by replacing `$1`, `$2` placeholders with interpolated parameters
- **sql.unsafe()**: Preserves the original sqlc-generated SQL without modification

> **Note:** In both cases, the plugin may still modify SQL to handle edge cases like duplicate column names (e.g., `id` becomes `id`, `id_2`, `id_3`).

#### Repository Pattern

Each SQL file in your `queries/` directory becomes its own encapsulated repository. For example:

```
queries/
├── customers.sql → CustomersRepository.ts
├── orders.sql → OrdersRepository.ts
└── products.sql → ProductsRepository.ts
```

All queries defined in a SQL file are grouped into a single repository service. This keeps related database operations together and provides clean dependency injection through Effect's `Layer` system.

#### Generated Output

For each repository, the builder generates:

- **Parameter schemas** - Type-safe input validation for each query
- **Result schemas** - Type-safe output parsing with proper null handling (`Option`)
- **Repository interface** - A typed interface defining all available operations
- **Service tag** - An Effect service tag for dependency injection
- **Live implementation** - The actual repository implementation using `SqlClient`
- **Layer export** - A ready-to-use `Layer` for providing the repository

#### Usage

```typescript
import { CustomersRepository, customersRepositoryLive } from "./repositories/CustomersRepository"
import { Effect, Layer } from "effect"
import { PgClient } from "effect/unstable/sql/PgClient"

const program = Effect.gen(function* () {
const repo = yield* CustomersRepository

// All queries from customers.sql are available as methods
const customer = yield* repo.getCustomer({ id: 1 })
const allCustomers = yield* repo.listCustomers()
yield* repo.createCustomer({ email: "new@example.com", name: "New Customer" })
})

// Provide the repository layer (requires SqlClient)
const runnable = program.pipe(
Effect.provide(customersRepositoryLive),
Effect.provide(/* your PgClient layer */)
)
```

#### Supported sqlc Commands

| Command | Supported | Effect Return Type | Description |
|---------|-----------|-------------------|-------------|
| `:one` | Yes | `Option.Option` | Returns at most one row |
| `:many` | Yes | `Result[]` | Returns zero or more rows |
| `:exec` | Yes | `void` | Executes without returning data |
| `:execrows` | Yes | `number` | Returns the number of affected rows |
| `:execresult` | No | - | Not yet implemented |
| `:copyfrom` | No | - | Not yet implemented |
| `:batchexec` | No | - | Not yet implemented |
| `:batchone` | No | - | Not yet implemented |
| `:batchmany` | No | - | Not yet implemented |

#### Supported sqlc Macros

| Macro | Supported | Description |
|-------|-----------|-------------|
| `sqlc.arg('name')` | Yes | Explicit parameter naming |
| `sqlc.narg('name')` | No | Nullable argument - not yet implemented |
| `sqlc.slice('name')` | No | Slice expansion - use `= ANY($1::type[])` instead (see below) |
| `sqlc.embed(table)` | Yes | Embed table columns into nested structures |

> **Note on `sqlc.slice`:** While `sqlc.slice()` is not supported, you can achieve the same result using PostgreSQL's `ANY` operator with array casting:
> ```sql
> -- Instead of: WHERE id IN (sqlc.slice('ids'))
> -- Use:
> WHERE id = ANY($1::int[])
> ```
> This generates a parameter typed as `Schema.Array(Schema.Int)` and works correctly with PostgreSQL.

### Future Builders (Planned)

| Builder | Description |
|---------|-------------|
| `effect-v3` | Effect v3 compatible code generation |
| `typescript` | Plain TypeScript with no Effect dependency |
| `zod-v4` | TypeScript with Zod v4 schemas for validation |

## Supported Database Engines

| Engine | Supported |
|--------|-----------|
| PostgreSQL | Yes |
| MySQL | No |
| SQLite | No |

## Type Mapping

The following table shows how PostgreSQL types are mapped to Effect Schema types:

| PostgreSQL Type | Effect Schema | Notes |
|-----------------|---------------|-------|
| `integer`, `int`, `int4`, `serial` | `Schema.Int` | |
| `bigint`, `int8`, `bigserial` | `BigIntFromString` | PostgreSQL returns bigint as string to preserve precision |
| `smallint`, `int2`, `smallserial` | `Schema.Int` | |
| `real`, `float4`, `double precision`, `float8` | `Schema.Number` | |
| `numeric`, `money` | `Schema.String` | Preserves precision |
| `boolean`, `bool` | `Schema.Boolean` | |
| `text`, `varchar`, `char`, `citext` | `Schema.String` | |
| `uuid` | `Schema.String` | |
| `date` | `Schema.Date` | |
| `timestamp`, `timestamptz` | `Schema.Date` | |
| `time`, `timetz`, `interval` | `Schema.String` | |
| `json`, `jsonb` | `Schema.Unknown` | |
| `bytea` | `Schema.Uint8Array` | |
| `inet`, `cidr`, `macaddr` | `Schema.String` | |
| Arrays (e.g., `int[]`) | `Schema.Array(...)` | Wraps the base type |
| Enums | `Schema.Literals([...])` | Generated from enum definition |

### Nullability

- **Parameters**: Nullable parameters use `Schema.optional()`, allowing callers to omit the field
- **Results**: Nullable results use `Schema.OptionFromNullOr()`, transforming `null` to `Option.None`

## Configuration

Configure the plugin in your `sqlc.yaml`:

```yaml
version: '2'
plugins:
- name: better-typescript
wasm:
url: https://github.com/eikster-dk/sqlc-gen-better-typescript/releases/download/v[version]/plugin.wasm
sha256: [calculatedSha]

sql:
- schema: schema/
queries: queries/
engine: postgresql
codegen:
- out: src/repositories
plugin: better-typescript
options:
builder: effect-v4-unstable
# debug: true
# debug_dir: debug
```

### Plugin Options

| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| `builder` | string | Yes | - | The code generation builder to use. Must be one of the available builders (e.g., `effect-v4-unstable`). |
| `disable_template_literals` | boolean | No | `false` | Preserve original sqlc SQL using `sql.unsafe()` instead of transforming to template literals. See [Preserving Original SQL](#preserving-original-sql). |
| `import_extension` | string | No | `""` | Explicit extension for generated relative imports. Allowed: `""`, `.js`, `.ts`. Use `.js` for Node ESM (`moduleResolution: nodenext`/`node16`). |
| `debug` | boolean | No | `false` | Enable debug mode to output intermediate representations and detailed logs during code generation. |
| `debug_dir` | string | No | `"debug"` | Directory where debug output files are written when debug mode is enabled. |

## Getting Started

1. Install sqlc: https://docs.sqlc.dev/en/latest/overview/install.html

2. Create your `sqlc.yaml` configuration (see above)

3. Write your SQL schema and queries

4. Run sqlc:
```bash
sqlc generate
```

5. Use the generated repositories in your Effect application

## Development

### Building the Plugin

```bash
make build
```

### Running Tests

```bash
make test
```

### Project Structure

```
.
├── cmd/plugin/ # Plugin source code
│ ├── main.go # Entry point
│ └── internal/
│ ├── builders/ # Code generation builders
│ ├── config/ # Plugin configuration
│ ├── mapper/ # sqlc to internal type mapping
│ ├── models/ # Internal data models
│ └── logger/ # Structured logging
├── examples/ # Example projects
│ └── effect-v4/ # Effect v4 example
└── dist/ # Built plugin artifacts
```

## License

See [LICENSE](LICENSE) file.