Skip to main content

Chapter 10: Schema Validation with Effect

Every application has a trust boundary — the point where data enters your system from an untrusted source: API requests, form submissions, URL parameters, localStorage, external APIs. At these boundaries, data must be validated.

Effect Schema provides a single source of truth that serves as both a TypeScript type and a runtime validator. Define it once, get types and validation for free.

Why Effect Schema

The Problem with Separate Types and Validators

// ❌ The type and the validator can drift apart
interface CreateProject {
name: string;
description?: string;
priority: "low" | "medium" | "high";
}

const validateCreateProject = (data: unknown): CreateProject => {
// Manual validation that must stay in sync with the interface
if (typeof data !== "object" || data === null) throw new Error("Invalid");
// ... 20 more lines of validation
};

When the interface changes, the validator must change too. They will inevitably get out of sync.

The Solution: Schema as Single Source of Truth

import { Schema } from "effect";

// Define ONCE — get both the type AND the validator
const CreateProject = Schema.Struct({
name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(100)),
description: Schema.optional(Schema.String),
priority: Schema.Literal("low", "medium", "high"),
});

// Infer the TypeScript type from the schema
type CreateProject = typeof CreateProject.Type;
// { name: string; description?: string; priority: "low" | "medium" | "high" }

// Validate unknown data at runtime
const validated = Schema.decodeUnknownSync(CreateProject)(rawData);

The type and the validation rules are the same object. They cannot drift apart.

Core Schema Types

Primitives

import { Schema } from "effect";

Schema.String; // string
Schema.Number; // number
Schema.Boolean; // boolean
Schema.BigInt; // bigint
Schema.Date; // Date (from string or number input)
Schema.Undefined; // undefined
Schema.Null; // null
Schema.Unknown; // unknown
Schema.Void; // void

Literals and Enums

// Literal values
Schema.Literal("active"); // "active"
Schema.Literal("active", "archived", "completed"); // "active" | "archived" | "completed"
Schema.Literal(1, 2, 3); // 1 | 2 | 3

// As a reusable type
const ProjectStatus = Schema.Literal("active", "archived", "completed");
type ProjectStatus = typeof ProjectStatus.Type; // "active" | "archived" | "completed"

Structs (Objects)

const User = Schema.Struct({
id: Schema.String,
name: Schema.String,
email: Schema.String,
age: Schema.Number,
isActive: Schema.Boolean,
});
type User = typeof User.Type;
// { id: string; name: string; email: string; age: number; isActive: boolean }

Optional and Nullable Fields

const Profile = Schema.Struct({
// Required field
name: Schema.String,

// Optional field (can be absent)
bio: Schema.optional(Schema.String),

// Optional with a default value
theme: Schema.optional(Schema.String).pipe(Schema.withDefault(() => "light")),

// Nullable field (present but can be null)
avatarUrl: Schema.NullOr(Schema.String),

// Optional AND nullable
nickname: Schema.optional(Schema.NullOr(Schema.String)),
});

Arrays and Records

// Arrays
const Tags = Schema.Array(Schema.String);
// string[]

// Non-empty arrays
const RequiredTags = Schema.NonEmptyArray(Schema.String);
// [string, ...string[]]

// Records (string-keyed objects)
const Config = Schema.Record({
key: Schema.String,
value: Schema.Unknown,
});
// Record<string, unknown>

Unions

// Union of schemas
const StringOrNumber = Schema.Union(Schema.String, Schema.Number);
// string | number

// Discriminated unions (preferred for complex types)
const Shape = Schema.Union(
Schema.Struct({
type: Schema.Literal("circle"),
radius: Schema.Number,
}),
Schema.Struct({
type: Schema.Literal("rectangle"),
width: Schema.Number,
height: Schema.Number,
}),
);
type Shape = typeof Shape.Type;
// { type: "circle"; radius: number } | { type: "rectangle"; width: number; height: number }

Filters and Refinements

Add validation constraints to base types:

const Email = Schema.String.pipe(
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
Schema.brand("Email"),
);

const PositiveNumber = Schema.Number.pipe(
Schema.positive(),
);

const ProjectName = Schema.String.pipe(
Schema.minLength(1),
Schema.maxLength(100),
Schema.trimmed(),
);

const Password = Schema.String.pipe(
Schema.minLength(8),
Schema.maxLength(128),
);

const PageNumber = Schema.Number.pipe(
Schema.int(),
Schema.greaterThanOrEqualTo(1),
);

const PageSize = Schema.Number.pipe(
Schema.int(),
Schema.greaterThanOrEqualTo(1),
Schema.lessThanOrEqualTo(100),
);

Custom Filters

const EvenNumber = Schema.Number.pipe(
Schema.filter((n) => n % 2 === 0, {
message: () => "Expected an even number",
})
);

const FutureDate = Schema.Date.pipe(
Schema.filter((date) => date > new Date(), {
message: () => "Date must be in the future",
})
);

Branded Types

Brands create nominal types that prevent mixing up structurally identical values:

import { Schema } from "effect";

const UserId = Schema.String.pipe(Schema.brand("UserId"));
type UserId = typeof UserId.Type;
// string & Brand<"UserId">

const ProjectId = Schema.String.pipe(Schema.brand("ProjectId"));
type ProjectId = typeof ProjectId.Type;
// string & Brand<"ProjectId">

// These are both strings, but TypeScript treats them as incompatible
function getProject(id: ProjectId): Effect.Effect<Project> { /* ... */ }

const userId: UserId = "user-123" as UserId;
getProject(userId); // ❌ Type error! UserId is not ProjectId

Decoding and Encoding

Decoding: External Data → Application Types

import { Schema } from "effect";

// Synchronous — throws on invalid data
const user = Schema.decodeUnknownSync(User)(jsonData);

// Effect-based — returns Effect with typed error
const user = yield* Schema.decodeUnknown(User)(jsonData);
// Type: Effect<User, ParseError, never>

// Either-based — returns Either
const result = Schema.decodeUnknownEither(User)(jsonData);
if (Either.isRight(result)) {
const user = result.right; // Valid User
} else {
const error = result.left; // ParseError with details
}

Encoding: Application Types → External Format

// Useful for API responses, localStorage, etc.
const json = Schema.encodeSync(User)(user);

Transformations (Decode ≠ Encode)

// A schema that accepts a date string and produces a Date object
const DateFromString = Schema.transform(
Schema.String, // From: string
Schema.Date, // To: Date
{
decode: (s) => new Date(s),
encode: (d) => d.toISOString(),
}
);

// Accepts "2024-01-15T00:00:00.000Z", produces Date object
// Encoding produces ISO string back

Real-World Schema Patterns

API Request Schemas

// features/projects/schemas/project-schemas.ts
import { Schema } from "effect";

// Create
export const CreateProjectSchema = Schema.Struct({
name: Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1, { message: () => "Project name is required" }),
Schema.maxLength(100, { message: () => "Project name must be under 100 characters" }),
),
description: Schema.optional(Schema.String.pipe(Schema.maxLength(5000))),
priority: Schema.optional(Schema.Literal("low", "medium", "high")).pipe(
Schema.withDefault(() => "medium" as const),
),
dueDate: Schema.optional(Schema.Date),
tagIds: Schema.optional(Schema.Array(Schema.String)),
});
export type CreateProjectInput = typeof CreateProjectSchema.Type;

// Update (all fields optional)
export const UpdateProjectSchema = Schema.Struct({
name: Schema.optional(
Schema.String.pipe(Schema.trimmed(), Schema.minLength(1), Schema.maxLength(100))
),
description: Schema.optional(Schema.NullOr(Schema.String.pipe(Schema.maxLength(5000)))),
status: Schema.optional(Schema.Literal("active", "archived", "completed")),
priority: Schema.optional(Schema.Literal("low", "medium", "high")),
dueDate: Schema.optional(Schema.NullOr(Schema.Date)),
});
export type UpdateProjectInput = typeof UpdateProjectSchema.Type;

// List query params
export const ProjectListQuerySchema = Schema.Struct({
organizationId: Schema.String,
status: Schema.optional(Schema.Literal("active", "archived", "completed")),
search: Schema.optional(Schema.String),
page: Schema.optional(Schema.NumberFromString).pipe(
Schema.withDefault(() => 1),
),
pageSize: Schema.optional(Schema.NumberFromString).pipe(
Schema.withDefault(() => 20),
),
sortBy: Schema.optional(Schema.Literal("name", "createdAt", "updatedAt")).pipe(
Schema.withDefault(() => "createdAt" as const),
),
sortOrder: Schema.optional(Schema.Literal("asc", "desc")).pipe(
Schema.withDefault(() => "desc" as const),
),
});
export type ProjectListQuery = typeof ProjectListQuerySchema.Type;

Domain Entity Schemas

// features/projects/domain/entities/project.ts
import { Schema } from "effect";

export const ProjectId = Schema.String.pipe(Schema.brand("ProjectId"));
export type ProjectId = typeof ProjectId.Type;

export const ProjectStatus = Schema.Literal("active", "archived", "completed");
export type ProjectStatus = typeof ProjectStatus.Type;

export const ProjectPriority = Schema.Literal("low", "medium", "high");
export type ProjectPriority = typeof ProjectPriority.Type;

export const Project = Schema.Struct({
id: ProjectId,
name: Schema.String,
description: Schema.NullOr(Schema.String),
status: ProjectStatus,
priority: ProjectPriority,
organizationId: Schema.String.pipe(Schema.brand("OrganizationId")),
createdById: Schema.String.pipe(Schema.brand("UserId")),
dueDate: Schema.NullOr(Schema.Date),
createdAt: Schema.Date,
updatedAt: Schema.Date,
});
export type Project = typeof Project.Type;

API Response Schemas

// Paginated response wrapper
export const PaginatedResponse = <A, I, R>(itemSchema: Schema.Schema<A, I, R>) =>
Schema.Struct({
items: Schema.Array(itemSchema),
total: Schema.Number.pipe(Schema.int(), Schema.nonNegative()),
page: Schema.Number.pipe(Schema.int(), Schema.positive()),
pageSize: Schema.Number.pipe(Schema.int(), Schema.positive()),
totalPages: Schema.Number.pipe(Schema.int(), Schema.nonNegative()),
});

// Usage
const ProjectListResponse = PaginatedResponse(Project);
type ProjectListResponse = typeof ProjectListResponse.Type;

Integration with TanStack Form

→ See Chapter 22: Forms for complete form integration.

// Quick preview: using Effect Schema for form validation
import { useForm } from "@tanstack/react-form";
import { Schema } from "effect";

function CreateProjectForm() {
const form = useForm({
defaultValues: {
name: "",
description: "",
priority: "medium" as const,
},
onSubmit: async ({ value }) => {
// Validate through schema before submitting
const result = Schema.decodeUnknownEither(CreateProjectSchema)(value);
if (Either.isLeft(result)) {
// Handle validation errors
return;
}
await createProject(result.right);
},
});

return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
<form.Field
name="name"
validators={{
onChange: ({ value }) => {
const result = Schema.decodeUnknownEither(
Schema.String.pipe(Schema.minLength(1), Schema.maxLength(100))
)(value);
return Either.isLeft(result)
? "Project name must be 1-100 characters"
: undefined;
},
}}
>
{(field) => (
<div>
<label htmlFor="name">Project Name</label>
<input
id="name"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors.length > 0 && (
<span className="text-red-500">{field.state.meta.errors[0]}</span>
)}
</div>
)}
</form.Field>
{/* more fields */}
</form>
);
}

Integration with TanStack Router Search Params

// Using Effect Schema with route search params
import { createFileRoute } from "@tanstack/react-router";
import { Schema } from "effect";

const ProjectSearchParams = Schema.Struct({
status: Schema.optional(Schema.Literal("active", "archived", "completed")),
search: Schema.optional(Schema.String),
page: Schema.optional(Schema.Number).pipe(Schema.withDefault(() => 1)),
sort: Schema.optional(Schema.Literal("name", "date")).pipe(
Schema.withDefault(() => "date" as const),
),
});

export const Route = createFileRoute("/_authenticated/projects/")({
validateSearch: (search) =>
Schema.decodeUnknownSync(ProjectSearchParams)(search),
component: ProjectListPage,
});

Sharing Schemas Between Frontend and Backend

In a monorepo, place shared schemas in a packages/shared package:

packages/shared/src/
├── schemas/
│ ├── project-schemas.ts # CreateProject, UpdateProject, etc.
│ ├── user-schemas.ts # CreateUser, UpdateUser, etc.
│ └── common-schemas.ts # Pagination, sorting, etc.
├── types/
│ └── index.ts # Re-export inferred types
└── index.ts

Both the frontend and backend import from @taskforge/shared:

// apps/web/src/features/projects/hooks/use-project-mutations.ts
import { CreateProjectSchema } from "@taskforge/shared/schemas/project-schemas";

// apps/api/src/routes/projects.ts
import { CreateProjectSchema } from "@taskforge/shared/schemas/project-schemas";

Same schema, same validation, same types — on both sides of the network boundary.

Performance Considerations

Validate Only at Trust Boundaries

Do not validate data at every function call. Validate at these boundaries:

  1. API request input — data from the client
  2. API response — data from external APIs
  3. Form submission — user input
  4. URL parameters — router search params
  5. localStorage/sessionStorage — persisted data
  6. WebSocket messages — real-time data

Inside your application, after data has passed validation, trust the types.

Compile Schemas for Hot Paths

For performance-critical paths, consider using Schema.decodeUnknownEither instead of sync variants and caching the decoder:

// Create the decoder once, reuse it
const decodeProject = Schema.decodeUnknownSync(Project);

// Use it multiple times
const project1 = decodeProject(data1);
const project2 = decodeProject(data2);

Summary

  • Single source of truth — the schema IS the type AND the validator
  • ✅ Use Schema.Struct for objects, Schema.Literal for enums, Schema.Union for unions
  • ✅ Add constraints with Schema.minLength, Schema.positive, Schema.pattern, Schema.filter
  • ✅ Use branded types (Schema.brand) to prevent ID mix-ups
  • ✅ Decode at trust boundaries with Schema.decodeUnknown or Schema.decodeUnknownSync
  • ✅ Share schemas between frontend and backend in a monorepo
  • ✅ Integrate with TanStack Form for field-level validation
  • ✅ Integrate with TanStack Router for type-safe search params

🎮 Try It: Schema Validation Playground

Try modifying the input data to see validation succeed or fail:

import { Schema, Either } from "effect";

// Define a schema — this is BOTH the type AND the validator
const CreateUser = Schema.Struct({
name: Schema.String.pipe(
  Schema.minLength(1),
  Schema.maxLength(50)
),
email: Schema.String.pipe(
  Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
),
age: Schema.Number.pipe(
  Schema.int(),
  Schema.greaterThanOrEqualTo(13),
  Schema.lessThanOrEqualTo(150)
),
});

// Try changing these values!
const testInputs = [
{ name: "Alice", email: "[email protected]", age: 30 },
{ name: "", email: "invalid-email", age: 10 },
{ name: "Bob", email: "[email protected]", age: 25 },
];

export default function App() {
const decode = Schema.decodeUnknownEither(CreateUser);

return (
  <div style={{ fontFamily: "sans-serif", padding: "1.5rem" }}>
    <h2>Schema Validation</h2>
    {testInputs.map((input, i) => {
      const result = decode(input);
      const isValid = Either.isRight(result);
      return (
        <div key={i} style={{
          padding: "1rem",
          margin: "0.5rem 0",
          borderRadius: "8px",
          border: "2px solid " + (isValid ? "#22c55e" : "#ef4444"),
          background: isValid ? "#f0fdf4" : "#fef2f2",
          color: "#1a1a1a"
        }}>
          <strong>{isValid ? "✅" : "❌"} Input {i + 1}:</strong>
          <pre style={{ margin: "0.5rem 0 0", fontSize: "0.85rem" }}>
            {JSON.stringify(input, null, 2)}
          </pre>
          {!isValid && (
            <p style={{ color: "#dc2626", margin: "0.5rem 0 0", fontSize: "0.85rem" }}>
              Validation failed
            </p>
          )}
        </div>
      );
    })}
  </div>
);
}