Skip to content

Models

Effect SQL's Model system provides a way to define domain models that automatically generate different schema variants for database operations and JSON APIs. This reduces boilerplate while ensuring type safety.

Why Models?

When working with databases, you often need different shapes of the same data:

  • Select - Full row with all columns
  • Insert - Row without auto-generated fields (id, created_at)
  • Update - Partial row for updates
  • JSON - API representation (may differ from database)

Without Models, you'd define each variant manually:

typescript
// Tedious and error-prone
const UserSelect = Schema.Struct({ id: Schema.Number, name: Schema.String, createdAt: Schema.Date })
const UserInsert = Schema.Struct({ name: Schema.String })
const UserUpdate = Schema.Struct({ id: Schema.Number, name: Schema.optional(Schema.String) })
const UserJson = Schema.Struct({ id: Schema.Number, name: Schema.String, createdAt: Schema.String })

With Models, you define once:

typescript
class User extends Model.Class<User>("User")({
  id: Model.Generated(Schema.Number),
  name: Schema.NonEmptyString,
  createdAt: Model.DateTimeInsertFromDate
}) {}

// All variants are automatically available:
User          // Select schema
User.insert   // Insert schema (no id, createdAt auto-set)
User.update   // Update schema (all fields optional except id)
User.json     // JSON API schema

Defining Models

Basic Model

typescript
import { Schema } from "effect"
import { Model } from "@effect/sql"

class User extends Model.Class<User>("User")({
  id: Model.Generated(Schema.Number),
  name: Schema.NonEmptyString,
  email: Schema.NonEmptyString,
  createdAt: Model.DateTimeInsertFromDate,
  updatedAt: Model.DateTimeUpdateFromDate
}) {}

Field Types

Model.Generated

For fields generated by the database (auto-increment IDs, etc.):

typescript
// Available in: select, update, json
// NOT available in: insert (generated by DB)
id: Model.Generated(Schema.Number)

Model.GeneratedByApp

For fields generated by your application (UUIDs, etc.):

typescript
// Available in: select, insert, update, json
// NOT available in: jsonCreate, jsonUpdate (generated by app)
id: Model.GeneratedByApp(Schema.UUID)

Model.Sensitive

For fields that shouldn't be exposed in JSON APIs:

typescript
// Available in: select, insert, update
// NOT available in: json, jsonCreate, jsonUpdate
passwordHash: Model.Sensitive(Schema.String)

Model.FieldOption

For optional/nullable fields:

typescript
// Nullable in database, optional in JSON
bio: Model.FieldOption(Schema.String)

DateTime Fields

Effect SQL provides special fields for timestamp columns:

typescript
// Inserted once, never updated
createdAt: Model.DateTimeInsertFromDate  // Date object to DB
createdAt: Model.DateTimeInsert          // String to DB
createdAt: Model.DateTimeInsertFromNumber // Epoch ms to DB

// Updated on every change
updatedAt: Model.DateTimeUpdateFromDate  // Date object to DB
updatedAt: Model.DateTimeUpdate          // String to DB
updatedAt: Model.DateTimeUpdateFromNumber // Epoch ms to DB

These automatically set the current time on insert/update.

JSON Fields

Store JSON data in text columns:

typescript
class Settings extends Model.Class<Settings>("Settings")({
  id: Model.Generated(Schema.Number),
  userId: Schema.Number,
  preferences: Model.JsonFromString(
    Schema.Struct({
      theme: Schema.Literal("light", "dark"),
      language: Schema.String
    })
  )
}) {}

// In database: {"theme":"dark","language":"en"} (string)
// In TypeScript: { theme: "dark", language: "en" } (object)

Using Models

With SqlSchema

typescript
import { SqlSchema } from "@effect/sql"

// Find one user
const findUserById = SqlSchema.findOne({
  Request: Schema.Number,
  Result: User,
  execute: (id) => sql`SELECT * FROM users WHERE id = ${id}`
})

// Find all users
const findAllUsers = SqlSchema.findAll({
  Request: Schema.Void,
  Result: User,
  execute: () => sql`SELECT * FROM users`
})

// Insert user
const insertUser = SqlSchema.single({
  Request: User.insert,
  Result: User,
  execute: (user) => sql`INSERT INTO users ${sql.insert(user)} RETURNING *`
})

With Repositories

Create a simple CRUD repository:

typescript
const UserRepo = yield* Model.makeRepository(User, {
  tableName: "users",
  spanPrefix: "UserRepo",
  idColumn: "id"
})

// Now you have:
const user = yield* UserRepo.insert({ name: "Alice", email: "alice@example.com" })
const found = yield* UserRepo.findById(1)  // Option<User>
const updated = yield* UserRepo.update({ id: 1, name: "Alice Smith" })
yield* UserRepo.delete(1)

With Data Loaders

Create batched data loaders for the N+1 problem:

typescript
const UserLoaders = yield* Model.makeDataLoaders(User, {
  tableName: "users",
  spanPrefix: "UserLoader",
  idColumn: "id",
  window: "50 millis",  // Batch window
  maxBatchSize: 100
})

// These automatically batch requests
const user1 = yield* UserLoaders.findById(1)
const user2 = yield* UserLoaders.findById(2)
// Only one SQL query: SELECT * FROM users WHERE id IN (1, 2)

Schema Variants

Select (Default)

The full row schema:

typescript
User  // Same as User schema
// { id: number, name: string, email: string, createdAt: Date, updatedAt: Date }

Insert

Fields required for insertion:

typescript
User.insert
// { name: string, email: string }
// (id, createdAt, updatedAt are auto-generated)

Update

Fields for updates (id required, others optional):

typescript
User.update
// { id: number, name?: string, email?: string }

JSON Variants

For API serialization:

typescript
User.json       // Full JSON representation
User.jsonCreate // For POST /users
User.jsonUpdate // For PATCH /users/:id

Custom Field Definitions

Create custom field types using Model.Field:

typescript
import { Model, Field } from "@effect/sql"

// Field available only in specific variants
const AdminOnly = <S extends Schema.Schema.Any>(schema: S) =>
  Model.Field({
    select: schema,
    insert: schema,
    update: schema
    // NOT in json variants
  })

class User extends Model.Class<User>("User")({
  id: Model.Generated(Schema.Number),
  name: Schema.NonEmptyString,
  isAdmin: AdminOnly(Schema.Boolean)  // Not exposed in JSON
}) {}

Branded IDs

Use branded types for type-safe IDs:

typescript
// Define branded ID types
const UserId = Schema.Number.pipe(Schema.brand("UserId"))
const PostId = Schema.Number.pipe(Schema.brand("PostId"))

class User extends Model.Class<User>("User")({
  id: Model.Generated(UserId),
  name: Schema.NonEmptyString
}) {}

class Post extends Model.Class<Post>("Post")({
  id: Model.Generated(PostId),
  userId: UserId,  // Type-safe reference
  title: Schema.String
}) {}

// Type error: Can't pass PostId where UserId expected
const post = { userId: postId }  // Error!

Overriding Generated Values

Use Model.Override to provide values for generated fields:

typescript
const user = yield* UserRepo.insert({
  name: "Alice",
  email: "alice@example.com",
  // Override auto-generated createdAt
  createdAt: Model.Override(new Date("2020-01-01"))
})

Examples

Complete User Model

typescript
import { Schema } from "effect"
import { Model } from "@effect/sql"

const UserId = Schema.Number.pipe(Schema.brand("UserId"))

class User extends Model.Class<User>("User")({
  // Auto-generated primary key
  id: Model.Generated(UserId),
  
  // Required fields
  name: Schema.NonEmptyString,
  email: Schema.NonEmptyString,
  
  // Optional field
  bio: Model.FieldOption(Schema.String),
  
  // Sensitive - not in JSON
  passwordHash: Model.Sensitive(Schema.String),
  
  // Timestamps
  createdAt: Model.DateTimeInsertFromDate,
  updatedAt: Model.DateTimeUpdateFromDate
}) {}

// Variants:
// User (select): { id, name, email, bio, passwordHash, createdAt, updatedAt }
// User.insert:   { name, email, bio?, passwordHash }
// User.update:   { id, name?, email?, bio?, passwordHash? }
// User.json:     { id, name, email, bio, createdAt, updatedAt } (no passwordHash)

JSON API Model

typescript
class Post extends Model.Class<Post>("Post")({
  id: Model.Generated(Schema.Number),
  title: Schema.NonEmptyString,
  content: Schema.String,
  
  // Store structured data as JSON
  metadata: Model.JsonFromString(
    Schema.Struct({
      tags: Schema.Array(Schema.String),
      readTime: Schema.Number
    })
  ),
  
  createdAt: Model.DateTimeInsertFromDate
}) {}

// JSON variants automatically handle serialization
const postJson = yield* Schema.encode(Post.json)(post)

Next Steps

Released under the MIT License.