tx-agent-kit
Core Concepts

Schema Contracts

How effect/Schema unifies API validation, OpenAPI generation, and type safety

Schema contracts are the backbone of tx-agent-kit's type safety across the full stack. Every API request and response is validated through effect/Schema, which also generates the OpenAPI specification that drives client codegen.

Contract schemas in packages/contracts

The packages/contracts package defines the API-facing schemas:

// packages/contracts/src/organizations.ts

import { Schema } from "effect"

export const createOrganizationRequestSchema = Schema.Struct({
  name: Schema.String.pipe(Schema.minLength(2), Schema.maxLength(64)),
})

export const organizationSchema = Schema.Struct({
  id: Schema.UUID,
  name: Schema.String,
  billingEmail: Schema.NullOr(Schema.String),
  isSubscribed: Schema.Boolean,
  subscriptionStatus: subscriptionStatusSchema,
  createdAt: Schema.String,
  updatedAt: Schema.String,
})

These schemas serve multiple purposes:

  1. The Effect HttpApi server uses them to validate incoming API requests.
  2. Responses are serialized through the schema, ensuring type safety.
  3. The schemas produce the OpenAPI spec that documents the API.
  4. The web and mobile clients generate typed hooks from the OpenAPI spec.

Effect schemas in packages/infra/db

The database layer has its own set of effect schemas in packages/infra/db/src/effect-schemas/. These schemas maintain table-to-schema parity: every Drizzle table has a corresponding effect schema:

// packages/infra/db/src/effect-schemas/organizations.ts

import { Schema } from "effect"

export const OrganizationRowSchema = Schema.Struct({
  id: Schema.String,
  name: Schema.String,
  billingEmail: Schema.NullOr(Schema.String),
  isSubscribed: Schema.Boolean,
  subscriptionStatus: Schema.Literal("active", "inactive", "trialing", "past_due", "canceled", "paused", "unpaid"),
  createdAt: Schema.Date,
  updatedAt: Schema.Date,
})

These schemas decode database rows into typed domain records, validate data at the persistence boundary, and ensure the database schema and the TypeScript types stay in sync.

The two schema layers

It is important to understand why there are two sets of schemas:

SchemaLocationPurpose
Contract schemaspackages/contractsAPI-facing. Define request/response shapes. Use String for JSON date serialization.
Effect schemaspackages/infra/db/src/effect-schemasDB-facing. Define table row shapes. Use Date directly.

The contract schemas handle JSON serialization concerns (dates as strings, nested objects). The DB schemas handle database row mapping. They are intentionally separate because the API shape and the database shape are often different.

Using schemas in routes

The Effect HttpApi automatically validates requests against contract schemas:

// apps/api - route definition
const OrganizationRoutes = HttpApiGroup.make("organizations").pipe(
  HttpApiGroup.add(
    HttpApiEndpoint.post("createOrganization", "/v1/organizations")
      .setPayload(createOrganizationRequestSchema)    // Validates request body
      .addSuccess(organizationSchema)                  // Serializes response
      .addError(UnauthorizedError, { status: 401 })
  ),
)

If a request does not match the createOrganizationRequestSchema schema, the API returns a 400 error with validation details automatically. No manual validation code is needed in the route handler.

Generating clients from schemas

The contract schemas produce an OpenAPI specification:

pnpm openapi:generate   # Generates apps/api/openapi.json

From this spec, typed API client hooks are generated for the web app:

pnpm api:client:generate  # Generates typed hooks from OpenAPI spec

This creates a chain of type safety:

effect/Schema (source of truth)
  -> OpenAPI spec (generated)
    -> TypeScript API client (generated)
      -> React hooks (generated)

If a schema changes, the generated chain updates automatically. The web app gets compile-time errors if it uses an outdated API shape.

Factories for test data

Every DB effect schema has a corresponding factory in packages/infra/db/src/factories/:

// packages/infra/db/src/factories/organizations.factory.ts

export const createOrganizationFactory = (overrides?: Partial<OrganizationInsert>): OrganizationInsert => ({
  id: generateId(),
  name: generateUniqueValue("Organization"),
  billingEmail: null,
  isSubscribed: false,
  subscriptionStatus: "inactive",
  createdAt: generateTimestamp(),
  updatedAt: generateTimestamp(),
  ...overrides,
})

Factories maintain table-to-factory parity: every table has a factory. This is enforced as an architectural invariant.

On this page