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:
- API request input — data from the client
- API response — data from external APIs
- Form submission — user input
- URL parameters — router search params
- localStorage/sessionStorage — persisted data
- 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.Structfor objects,Schema.Literalfor enums,Schema.Unionfor 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.decodeUnknownorSchema.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: