Back to Portfolio
ArticleTechnologyApr 12, 2026

Building Typesafe Databases with Effect

Event
The Innovation Lab
Overview

Effect Schema as your single source of truth gives you richer types than any ORM, and hand-written migrations as composable Effects give you full control. Contract tests prove your schema and database agree, while migration tests make evolution safe.

Technologies
Effect-TSPostgreSQLSQLMigrationsTestingSchema
Building Typesafe Databases with Effect preview
Introduction

Overview

Your database is an external system. It has its own type system, its own schema, and communicates over a network boundary. For every other external system — APIs, webhooks, payment providers — you validate at the boundary and write tests. But for the database, the one external system your entire app depends on, most teams just hope.

Effect gives you the tools to do better. Rich schemas that carry validation, transformations, and test data generation. Hand-written migrations that can alter structure and transform data in one atomic operation. And something almost nobody does — contract tests that prove your schema and database actually agree, and migration tests that prove a user from a year ago still works after seven migrations.

Rich Schemas

Effect Schema carries validation, transformations, arbitrary generation, and frontend error messages. Far richer than DB column types or ORM models.

Own Your Migrations

Hand-written migrations as composable Effects. Structure changes and data transformations in the same atomic operation — testable end to end.

Test the Boundary

Contract tests prove schema and DB agree. Migration tests prove transitions are safe. Your database is an external system — test it like one.

Code examples use Effect v4 beta with unstable import paths.

Source of Truth

Effect Schema: Richer Than Any ORM Model

A database table gives you column types. varchar, integer, timestamp — that is the extent of what the database knows about your data. An ORM model adds type mappings on top, translating between language types and SQL types. But an Effect Schema carries far more information than either.

Validation Rules and Constraints

Email must be valid, age must be positive, name must be non-empty. Constraints live in the schema, not scattered across middleware or database triggers.

Encode/Decode Transformations

Date objects become ISO strings for storage, branded types enforce identity semantics, and the schema handles both directions automatically.

Arbitrary Generation

Property-based test data generated FROM the schema via Schema.toArbitrary(). No factory functions, no hardcoded fixtures. The schema defines what valid data looks like.

Frontend-Legible Error Messages

TreeFormatter produces structured, user-facing validation errors. The schema describes not just what the data is, but how to explain violations to users.

Example

One Definition, Everything Derives

The schema defines what the data is. Everything downstream — API request and response shapes, service contracts, property-based test data, and what the database stores — derives from this single definition. No parallel type declarations that drift apart. No ORM model that disagrees with your application types. One source of truth, enforced by the type system.

The Problem

Two Sources of Truth

ORMs promise to bridge the gap between your application and your database. In practice, they create two separate descriptions of your data that must stay in sync — and the gap between them is where bugs live.

On one side, your application defines types, validation logic, and business rules. On the other, the ORM maintains its own schema: migration history, column types, and relationship mappings. These two descriptions evolve independently, and you discover disagreements through careful sandbox testing — or when errors reach production.

Source 1: Your Code

TypeScript types, validation logic, business rules. This is what your application believes the data looks like.

  • Interface and class definitions
  • Validation constraints
  • Transformation logic
Source 2: The ORM

Database schema, migration history, column types. This is what the database actually stores.

  • Generated migration files
  • Column type mappings
  • Relationship definitions
The Gap

The space between the two where drift lives. You discover disagreements through pain, not tooling.

  • Drift discovered in production
  • Sandbox testing as verification
  • Manual review of generated SQL

Migrations Without a Safety Net

ORM-generated migrations handle structural changes — ALTER TABLE, ADD COLUMN — but they cannot safely handle data transformations. Splitting a name column into first and last name, backfilling computed values, or migrating enum representations require custom logic that the ORM cannot generate.

When these migrations go wrong, the consequences are catastrophic: data loss, corrupted records, failed rollbacks. The standard mitigation is staging environments with replicated production data — enormous operational overhead to compensate for a tool that cannot express what you need. The alternative is not "no ORM." It is one source of truth with enforcement: a schema that defines the data, hand-written migrations that transform it, and tests that prove both are correct.

Querying

Effect SQL: Tagged Template Literals

Effect SQL uses tagged template literals for queries — no ORM abstraction layer, no query builder DSL. You write SQL directly, with parameters safely interpolated and the full power of your database available.

Query and Insert Patterns

The SqlClient is accessed through Effect's dependency injection. Template literals handle parameterization, and sql.insert maps object fields to columns automatically:

Template LiteralsSafe ParameterizationSchema Encoding
Typed Queries with SqlSchema

For common patterns, SqlSchema.findAll and SqlSchema.findOne provide a cleaner alternative that wires up Effect Schema decoding automatically — you define the query once and get fully typed results without manual decoding. But underneath, it is still the same tagged template approach. No magic query generation.

Schema Evolution

Migrations as Effect Programs

Each migration is a standalone Effect program — not a string of SQL in a YAML file, not an auto-generated diff. You have the full Effect runtime available: conditional logic, multi-step operations, data backfills, and error handling, all within a single composable program.

Initial Migration

Migration files export a default Effect that receives the SQL client from the context. The Migrator handles ordering, tracking, and transactional execution:

Effect ProgramAuto-OrderedTransactional

But look at that migration — it is raw SQL. Column names, types, constraints, all hand-written strings. Meanwhile, your TypeScript code has an Effect Schema defining the exact same entity with full type information. What connects the migration to the schema?

Key Insight

The Database Is an External System

PostgreSQL looks like an internal service — it sits in your docker compose, you control the schema, it feels like part of your application. But step back and look at the characteristics:

Its own type system — TEXT, TIMESTAMPTZ, JSONB, INTEGER. Independent of TypeScript and not interchangeable.

Its own schema — table definitions, constraints, and indexes that can drift from your code without warning.

A network boundary — you communicate over TCP. It is a separate process with its own lifecycle.

Independent changes — someone runs a manual migration, a DBA alters a column. Your code finds out at runtime.

The Testing Double Standard
External SystemHas Own TypesNetwork BoundaryYou Test It?
Payment APIJSON schemaHTTPS
Webhook ProviderEvent schemaHTTPS
PostgreSQLSQL typesTCP

The Disconnect

TypeScriptEffect Schema
SQLMigration

Nothing programmatically connects these. If you fat-finger a column name or use the wrong type, nothing catches it until production.

Test Infrastructure

Setting Up the Test Database

Setting up test infrastructure is straightforward with Effect layers. You need two things: a running Postgres instance and a composed layer that connects to it and runs migrations. The database lifecycle is a vitest global setup; the layer composition is pure Effect.

Vitest Global Setup

A vitest globalSetup file spins up a Postgres container before any tests run and tears it down after. No docker-compose, no external scripts — just Node child processes.

The pg_isready loop waits for Postgres to accept connections before tests start. The container runs on a non-default port to avoid collisions with any local Postgres instance.

Composing Layers, Not Building a Test Harness

You are composing layers — the same mechanism Effect uses for dependency injection everywhere. The test layer provides a SqlClient backed by a real Postgres instance with real migrations applied. Every test that uses this layer gets a fully migrated database. No mocks, no in-memory substitutes, no test-specific abstractions.

The First Payoff

Contract Tests: Schema Meets Database

Contract tests are the enforcement mechanism. They make "one source of truth" real by proving the schema and the database actually agree. The key insight: the current schema is always the test fixture. No version management, no historical schemas, no UserV1 / UserV2. You change the schema, write a migration, and the contract test uses the current schema — it either passes or it fails.

Schema Round-Trip Test

The core contract test: generate a random valid entity from the schema, encode it, insert it into the database, read it back, decode it, and compare. If the migration created wrong column names, wrong types, or missing columns — this test fails.

Arbitrary generation means you are testing the schema's full range, not one hand-picked object. Every valid combination of branded IDs, constrained strings, and date transformations gets exercised.

The Second Payoff

Migration Tests: Safe Evolution

A Drizzle migration can ALTER TABLE but it cannot touch the data. If your migration needs to rename a column and backfill values, you write the DDL in the migration and the data transformation... somewhere else. A script. A manual query. Hope.

Effect migrations are just Effects. Structure changes and data transformations live in the same atomic operation — and the whole thing is testable.

Migrations That Transform Data
The Year-Old User

A user signed up a year ago. Their data was inserted under migration 1. You have shipped 7 migrations since then — each one altering structure, transforming data. When they log in today, does their data still work?

Why This Matters

The INSERT succeeding after migration 1 proves the migration created a valid table. The data surviving migrations 2 through 7 proves every transition preserved it. The migration files are the sync truth between your schema and the database — this test proves they work.

Summary

Conclusion

Your database is an external system with its own type system, its own schema, and its own opinions about your data. For every other external boundary, you validate and test. Effect gives you the tools to do the same for your database — rich schemas as the single source of truth, migrations as composable Effects, and contract tests that prove they agree.

The path from here is clear: start with rich schemas that carry more than column types. Own your migrations so you control exactly how data transforms. Recognize that the database is an adapter, not a trusted internal service. Write contract tests that prove schema and storage agree. And write migration tests that verify transitions before they touch real data.

Takeaways

Key Principles

Rich Schemas

Effect Schema carries validation, transformations, arbitraries, and frontend error messages in a single definition. Far richer than column types or ORM models — and everything downstream derives from it.

Database as Adapter

Your database is an external system with its own type system, its own schema, and its own opinions about your data. The same boundary testing you would do for any third-party API applies here.

Contract Tests

The current schema is always the test fixture. No version management, no historical schemas to maintain. Change the schema, write a migration, run the test. The round-trip proves they agree.

Migration Tests

Test data transformations BEFORE they touch real data. Seed, migrate, verify. This is the exact scenario where ORMs cannot help and production breaks.

Honest Trade-Offs

This approach requires owning your SQL — you need to understand what the database is actually doing

You write and maintain migrations by hand, including rollback logic

You need Effect familiarity across the team, not just one person

You need to write (and run) the tests — they do not exist by default

If your team is not there yet, that is fine — but know what the ORM is costing you. Every any cast, every migration you cannot test, every production schema drift you discover at 2 AM — those are the costs. When you are ready for a better path, the tools exist.

Ready to transform your engineering?

Whether you need technical leadership, enterprise development, or team optimization—let's discuss how we can help.