Skip to content

Parameters & Interpolation

Proper parameter handling is crucial for both security (preventing SQL injection) and performance (prepared statement caching). Effect SQL makes it easy to do the right thing.

How Interpolation Works

When you use ${} in a query template, Effect SQL analyzes the value and handles it appropriately:

Primitive Values

Primitive values become parameterized:

typescript
const name = "Alice"
const age = 30

const users = yield* sql`
  SELECT * FROM users 
  WHERE name = ${name} AND age = ${age}
`
// Query: SELECT * FROM users WHERE name = $1 AND age = $2
// Params: ["Alice", 30]

Supported types:

  • Strings
  • Numbers (including bigint)
  • Booleans
  • Dates
  • null
  • Uint8Array (for binary data)

Arrays

Arrays are converted to a comma-separated list of parameters:

typescript
const ids = [1, 2, 3]

const users = yield* sql`SELECT * FROM users WHERE id IN ${sql.in(ids)}`
// Query: SELECT * FROM users WHERE id IN ($1,$2,$3)
// Params: [1, 2, 3]

TIP

Always use sql.in() for array values. Don't try to interpolate arrays directly.

Objects

Objects are converted to column-value pairs for INSERT/UPDATE:

typescript
const data = { name: "Alice", email: "alice@example.com" }

yield* sql`INSERT INTO users ${sql.insert(data)}`
// Query: INSERT INTO users ("name","email") VALUES ($1,$2)
// Params: ["Alice", "alice@example.com"]

yield* sql`UPDATE users SET ${sql.update(data)} WHERE id = ${1}`
// Query: UPDATE users SET "name" = $1, "email" = $2 WHERE id = $3
// Params: ["Alice", "alice@example.com", 1]

Fragments

SQL fragments are embedded directly:

typescript
const condition = sql`active = ${true}`
const orderBy = sql.literal("ORDER BY created_at DESC")

const users = yield* sql`SELECT * FROM users WHERE ${condition} ${orderBy}`
// Query: SELECT * FROM users WHERE active = $1 ORDER BY created_at DESC
// Params: [true]

Parameter Placeholders

Different databases use different placeholder styles. Effect SQL handles this automatically:

DatabasePlaceholder Style
PostgreSQL$1, $2, $3
MySQL?, ?, ?
SQLite?, ?, ?
SQL Server@p1, @p2, @p3

You write the same code; Effect SQL generates the correct SQL for your database.

Dynamic Queries

Conditional Clauses

Build queries dynamically based on conditions:

typescript
interface QueryOptions {
  name?: string
  minAge?: number
  maxAge?: number
  active?: boolean
}

const findUsers = (options: QueryOptions) =>
  Effect.gen(function* () {
    const sql = yield* SqlClient.SqlClient
    
    const conditions: Array<Statement.Fragment> = []
    
    if (options.name) {
      conditions.push(sql`name ILIKE ${`%${options.name}%`}`)
    }
    if (options.minAge !== undefined) {
      conditions.push(sql`age >= ${options.minAge}`)
    }
    if (options.maxAge !== undefined) {
      conditions.push(sql`age <= ${options.maxAge}`)
    }
    if (options.active !== undefined) {
      conditions.push(sql`active = ${options.active}`)
    }
    
    const whereClause = conditions.length > 0
      ? sql`WHERE ${sql.and(conditions)}`
      : sql``
    
    return yield* sql<User>`SELECT * FROM users ${whereClause}`
  })

Dynamic Column Selection

typescript
const selectColumns = (columns: string[]) =>
  Effect.gen(function* () {
    const sql = yield* SqlClient.SqlClient
    
    // Escape column names for safety
    const columnList = sql.csv(columns.map(c => sql(c)))
    
    return yield* sql`SELECT ${columnList} FROM users`
  })

Dynamic Table Names

typescript
const queryTable = (tableName: string) =>
  Effect.gen(function* () {
    const sql = yield* SqlClient.SqlClient
    
    // sql() escapes the identifier
    return yield* sql`SELECT * FROM ${sql(tableName)}`
  })

WARNING

Only use trusted table names. Even with escaping, allowing arbitrary user input for table names is risky.

Identifier Escaping

Use sql() (calling the client as a function with a string) to create escaped identifiers:

typescript
const tableName = "user's table"  // Has special characters
const columnName = "select"       // Reserved word

yield* sql`SELECT ${sql(columnName)} FROM ${sql(tableName)}`
// PostgreSQL: SELECT "select" FROM "user's table"
// MySQL: SELECT `select` FROM `user's table`

Name Transformations

Configure automatic name transformations in the client:

typescript
import { String } from "effect"

const DatabaseLive = PgClient.layer({
  // Transform identifiers in queries: camelCase → snake_case
  transformQueryNames: String.camelToSnake,
  // Transform column names in results: snake_case → camelCase
  transformResultNames: String.snakeToCamel
})

With this configuration:

typescript
// Your TypeScript code uses camelCase
yield* sql`SELECT * FROM users WHERE ${sql("createdAt")} > ${date}`
// Becomes: SELECT * FROM users WHERE "created_at" > $1

// Results come back as camelCase
const user = yield* sql<{ createdAt: Date }>`SELECT created_at FROM users`
// user.createdAt (not user.created_at)

// Inserts also transform
yield* sql`INSERT INTO users ${sql.insert({ firstName: "Alice" })}`
// Becomes: INSERT INTO users ("first_name") VALUES ($1)

JSON Values

For JSON columns, use the database-specific JSON helper:

typescript
// PostgreSQL
import { PgClient } from "@effect/sql-pg"

const pg = yield* PgClient.PgClient

yield* pg`INSERT INTO data ${pg.insert({ 
  config: pg.json({ theme: "dark", language: "en" })
})}`
// The JSON is properly serialized and typed

Binary Data

Binary data is passed as Uint8Array:

typescript
const imageData = new Uint8Array([...])

yield* sql`INSERT INTO files ${sql.insert({ 
  name: "image.png",
  data: imageData 
})}`

NULL Handling

null is handled correctly:

typescript
const middleName: string | null = null

yield* sql`INSERT INTO users ${sql.insert({ 
  name: "Alice",
  middleName  // Will be NULL in the database
})}`

// Querying for NULL
yield* sql`SELECT * FROM users WHERE middle_name IS NULL`

TIP

Use IS NULL / IS NOT NULL in SQL, not = NULL. Effect SQL doesn't transform equality checks with null automatically.

Empty Arrays

Empty arrays need special handling to avoid invalid SQL:

typescript
const ids: number[] = []

// BAD: This would generate invalid SQL
// yield* sql`SELECT * FROM users WHERE id IN ${sql.in(ids)}`
// Would be: SELECT * FROM users WHERE id IN ()

// GOOD: sql.in with column name handles empty arrays
yield* sql`SELECT * FROM users WHERE ${sql.in("id", ids)}`
// Generates: SELECT * FROM users WHERE 1=0 (always false)

Alternatively, check before querying:

typescript
const users = ids.length === 0
  ? Effect.succeed([])
  : sql<User>`SELECT * FROM users WHERE id IN ${sql.in(ids)}`

Prepared Statement Caching

Effect SQL uses parameterized queries, which enables prepared statement caching in most databases. This means:

  1. The query text is parsed and planned once
  2. Subsequent executions reuse the plan
  3. Different parameters don't create new plans
typescript
// These use the same prepared statement:
yield* sql`SELECT * FROM users WHERE id = ${1}`
yield* sql`SELECT * FROM users WHERE id = ${2}`
yield* sql`SELECT * FROM users WHERE id = ${3}`

The statement cache is managed by the database driver and typically limited by connection.

Best Practices

1. Never Concatenate User Input

typescript
// ❌ DANGEROUS: SQL injection vulnerability
const name = req.query.name
yield* sql.unsafe(`SELECT * FROM users WHERE name = '${name}'`)

// ✅ SAFE: Parameterized query
const name = req.query.name
yield* sql`SELECT * FROM users WHERE name = ${name}`

2. Use Helpers for Complex Values

typescript
// ✅ GOOD: Use sql.insert for objects
yield* sql`INSERT INTO users ${sql.insert(userData)}`

// ✅ GOOD: Use sql.in for arrays
yield* sql`SELECT * FROM users WHERE ${sql.in("id", userIds)}`

// ✅ GOOD: Use sql.and/sql.or for conditions
yield* sql`SELECT * FROM users WHERE ${sql.and(conditions)}`

3. Escape Identifiers Properly

typescript
// ✅ GOOD: sql() escapes identifiers
yield* sql`SELECT * FROM ${sql(tableName)}`

// ❌ BAD: Direct string interpolation
yield* sql.unsafe(`SELECT * FROM ${tableName}`)

4. Handle Empty Collections

typescript
// ✅ GOOD: Check for empty arrays
if (ids.length > 0) {
  yield* sql`SELECT * FROM users WHERE ${sql.in("id", ids)}`
}

// ✅ GOOD: Use sql.in with column name (handles empty arrays)
yield* sql`SELECT * FROM users WHERE ${sql.in("id", ids)}`

Next Steps

Released under the MIT License.