| name | schema |
| description | This skill should be used when the user asks about "Effect Schema", "Schema.Struct", "Schema.decodeUnknown", "data validation", "parsing", "Schema.transform", "Schema filters", "Schema annotations", "JSON Schema", "Schema.Class", "Schema branded types", "encoding", "decoding", "Schema.parseJson", or needs to understand how Effect handles data validation and transformation. |
| version | 1.0.0 |
Schema in Effect
Overview
Effect Schema provides:
- Type-safe validation - Runtime checks with TypeScript inference
- Bidirectional transformation - Decode from external, encode for output
- Composable schemas - Build complex types from primitives
- Error messages - Detailed, customizable validation errors
- Interop - JSON Schema, Pretty Printing, Arbitrary generation
Schema Best Practices
1. Tagged Unions Over Optional Properties
AVOID optional properties. USE tagged unions instead. This makes states explicit and enables exhaustive pattern matching.
const User = Schema.Struct({
id: Schema.String,
name: Schema.String,
email: Schema.optional(Schema.String),
verifiedAt: Schema.optional(Schema.Date),
suspendedReason: Schema.optional(Schema.String),
});
const User = Schema.Union(
Schema.Struct({
_tag: Schema.Literal("Unverified"),
id: Schema.String,
name: Schema.String,
}),
Schema.Struct({
_tag: Schema.Literal("Active"),
id: Schema.String,
name: Schema.String,
email: Schema.String,
verifiedAt: Schema.Date,
}),
Schema.Struct({
_tag: Schema.Literal("Suspended"),
id: Schema.String,
name: Schema.String,
email: Schema.String,
suspendedReason: Schema.String,
}),
);
Why tagged unions:
- No impossible states (suspended user always has a reason)
- Exhaustive matching catches missing cases
- Self-documenting state machine
- Works perfectly with Match.tag
2. Class-Based Schemas Over Struct Schemas
PREFER Schema.Class over Schema.Struct. Classes give you methods, Schema.is() type guards, and better ergonomics.
const UserStruct = Schema.Struct({
id: Schema.String,
firstName: Schema.String,
lastName: Schema.String,
email: Schema.String,
});
type User = Schema.Schema.Type<typeof UserStruct>;
class User extends Schema.Class<User>("User")({
id: Schema.String,
firstName: Schema.String,
lastName: Schema.String,
email: Schema.String,
}) {
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
get emailDomain() {
return this.email.split("@")[1];
}
withEmail(email: string) {
return new User({ ...this, email });
}
}
const user = Schema.decodeUnknownSync(User)(data);
console.log(user.fullName);
console.log(Schema.is(User)(user));
For tagged unions with classes:
class Unverified extends Schema.TaggedClass<Unverified>()("Unverified", {
id: Schema.String,
name: Schema.String,
}) {}
class Active extends Schema.TaggedClass<Active>()("Active", {
id: Schema.String,
name: Schema.String,
email: Schema.String,
verifiedAt: Schema.Date,
}) {
get isRecent() {
return Date.now() - this.verifiedAt.getTime() < 86400000;
}
}
class Suspended extends Schema.TaggedClass<Suspended>()("Suspended", {
id: Schema.String,
name: Schema.String,
suspendedReason: Schema.String,
}) {}
const User = Schema.Union(Unverified, Active, Suspended);
type User = Schema.Schema.Type<typeof User>;
3. Schema.is() with Match Patterns
USE Schema.is() as type guards in Match.when patterns. This combines Schema validation with Match's exhaustive checking.
import { Schema, Match } from "effect";
class Circle extends Schema.TaggedClass<Circle>()("Circle", {
radius: Schema.Number,
}) {
get area() {
return Math.PI * this.radius ** 2;
}
}
class Rectangle extends Schema.TaggedClass<Rectangle>()("Rectangle", {
width: Schema.Number,
height: Schema.Number,
}) {
get area() {
return this.width * this.height;
}
}
const Shape = Schema.Union(Circle, Rectangle);
type Shape = Schema.Schema.Type<typeof Shape>;
const describeShape = (shape: Shape) =>
Match.value(shape).pipe(
Match.when(Schema.is(Circle), (c) => `Circle with radius ${c.radius}`),
Match.when(Schema.is(Rectangle), (r) => `${r.width}x${r.height} rectangle`),
Match.exhaustive,
);
const processUnknown = (input: unknown) => {
if (Schema.is(Circle)(input)) {
console.log(`Circle area: ${input.area}`);
}
};
Schema.is() vs Match.tag:
const handleUser = (user: User) =>
Match.value(user).pipe(
Match.tag("Active", (u) => sendEmail(u.email)),
Match.tag("Suspended", (u) => logSuspension(u.suspendedReason)),
Match.tag("Unverified", () => sendVerificationReminder()),
Match.exhaustive,
);
const handleUnknown = (input: unknown) =>
Match.value(input).pipe(
Match.when(Schema.is(Active), (u) => u.isRecent),
Match.when(Schema.is(Suspended), () => false),
Match.orElse(() => false),
);
NEVER access ._tag directly:
if (user._tag === "Active") { ... }
const isActive = user._tag === "Active"
type UserTag = User["_tag"]
const hasActive = users.some((u) => u._tag === "Active")
const activeUsers = users.filter((u) => u._tag === "Active")
const activeCount = users.filter((u) => u._tag === "Active").length
const hasActive = users.some(Schema.is(Active))
const activeUsers = users.filter(Schema.is(Active))
const activeCount = users.filter(Schema.is(Active)).length
const handleUser = Match.type<User>().pipe(
Match.tag("Active", (u) => ...),
Match.exhaustive
)
const isActive = Schema.is(Active)
if (isActive(user)) { ... }
4. Never Use Schema.Any or Schema.Unknown for Type Weakening
Schema.Any and Schema.Unknown are ONLY permitted when the value is genuinely unconstrained at the domain level. Using them to avoid writing a proper schema is type weakening and defeats the purpose of Schema-first modeling.
Semantically correct uses (ALLOWED):
class NetworkError extends Schema.TaggedError<NetworkError>()("NetworkError", {
url: Schema.String,
cause: Schema.Unknown,
}) {}
class CacheEntry extends Schema.Class<CacheEntry>("CacheEntry")({
key: Schema.String,
value: Schema.Unknown,
ttl: Schema.Number,
}) {}
const RawJson = Schema.parseJson();
Type weakening (FORBIDDEN):
const UserResponse = Schema.Struct({
data: Schema.Unknown,
});
const ApiResponse = Schema.Struct({
body: Schema.Any,
});
const input: Schema.Schema<unknown> = Schema.Unknown;
const config = Schema.Struct({
settings: Schema.Any,
});
How to fix type-weakened schemas:
const ApiResponse = Schema.Struct({
data: Schema.Unknown,
meta: Schema.Any,
});
class ApiResponse extends Schema.Class<ApiResponse>("ApiResponse")({
data: Schema.Struct({
users: Schema.Array(User),
total: Schema.Number,
}),
meta: Schema.Struct({
page: Schema.Number,
perPage: Schema.Number,
requestId: Schema.String,
}),
}) {}
Rule of thumb: If you can describe the shape of the data, you MUST define a proper schema. Schema.Unknown is only for values that are genuinely opaque (caught exceptions, plugin payloads from unknown sources, serialized blobs you pass through without inspecting). Schema.Any should almost never appear in application code.
Basic Schemas
import { Schema } from "effect";
const str = Schema.String;
const num = Schema.Number;
const bool = Schema.Boolean;
const bigint = Schema.BigInt;
const status = Schema.Literal("pending", "active", "completed");
enum Color {
Red,
Green,
Blue,
}
const color = Schema.Enums(Color);
Decoding and Encoding
Decoding (External → Internal)
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number,
});
const person = Schema.decodeUnknownSync(Person)({ name: "Alice", age: 30 });
const person = yield * Schema.decodeUnknown(Person)(input);
const result = Schema.decodeUnknownEither(Person)(input);
Encoding (Internal → External)
const encoded = Schema.encodeSync(Person)(person);
const encoded = yield * Schema.encode(Person)(person);
Struct Schemas
const User = Schema.Struct({
id: Schema.Number,
name: Schema.String,
email: Schema.String,
createdAt: Schema.Date,
});
const UserWithOptional = Schema.Struct({
id: Schema.Number,
name: Schema.String,
nickname: Schema.optional(Schema.String),
});
const UserWithDefault = Schema.Struct({
id: Schema.Number,
role: Schema.optional(Schema.String).pipe(Schema.withDefault(() => "user")),
});
Array and Record
const Numbers = Schema.Array(Schema.Number);
const Users = Schema.Array(User);
const NonEmptyStrings = Schema.NonEmptyArray(Schema.String);
const StringRecord = Schema.Record({
key: Schema.String,
value: Schema.Number,
});
Union and Discriminated Unions
const StringOrNumber = Schema.Union(Schema.String, Schema.Number);
const Shape = Schema.Union(
Schema.Struct({
_tag: Schema.Literal("Circle"),
radius: Schema.Number,
}),
Schema.Struct({
_tag: Schema.Literal("Rectangle"),
width: Schema.Number,
height: Schema.Number,
}),
);
Transformations
Schema.transform
const NumberFromString = Schema.transform(Schema.String, Schema.Number, {
decode: (s) => parseFloat(s),
encode: (n) => String(n),
});
Built-in Transformations
const num = Schema.NumberFromString;
const date = Schema.DateFromString;
const jsonData = Schema.parseJson(
Schema.Struct({
name: Schema.String,
}),
);
JSON Parsing with Schema.parseJson
NEVER use JSON.parse() directly. Always use Schema.parseJson to combine JSON parsing with schema validation in one step.
Why Schema.parseJson over JSON.parse
const data = JSON.parse(jsonString);
const parsed = JSON.parse(jsonString);
const validated = Schema.decodeUnknownSync(MySchema)(parsed);
const MyData = Schema.parseJson(
Schema.Struct({
name: Schema.String,
count: Schema.Number,
}),
);
const data = Schema.decodeUnknownSync(MyData)('{"name": "test", "count": 42}');
const program = Schema.decodeUnknown(MyData)(jsonString);
Schema.parseJson Benefits
- Single failure point - Invalid JSON or invalid structure both produce ParseError
- Type safety - Result is fully typed, never
any
- Effect integration - Works seamlessly with Effect error handling
- Detailed errors - ParseError includes path and validation details
Common Patterns
const ApiResponse = Schema.parseJson(
Schema.Struct({
success: Schema.Boolean,
data: Schema.Struct({
id: Schema.String,
name: Schema.String,
}),
}),
);
const WithDate = Schema.parseJson(
Schema.Struct({
createdAt: Schema.Date,
}),
);
const NestedConfig = Schema.parseJson(
Schema.Struct({
settings: Schema.parseJson(
Schema.Struct({
theme: Schema.String,
}),
),
}),
);
In Effect Programs
import { Effect, Schema } from "effect";
const ConfigSchema = Schema.parseJson(
Schema.Struct({
apiKey: Schema.String,
endpoint: Schema.String,
retries: Schema.Number,
}),
);
const loadConfig = (jsonString: string) =>
Effect.gen(function* () {
const config = yield* Schema.decodeUnknown(ConfigSchema)(jsonString);
return config;
});
const program = loadConfig(rawJson).pipe(
Effect.catchTag("ParseError", (e) => Effect.fail(new ConfigurationError({ cause: e }))),
);
Filters (Validation)
const Email = Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/), Schema.annotations({ identifier: "Email" }));
const Username = Schema.String.pipe(Schema.minLength(3), Schema.maxLength(20), Schema.pattern(/^[a-z0-9_]+$/));
const Age = Schema.Number.pipe(Schema.int(), Schema.between(0, 150));
const PositiveNumber = Schema.Number.pipe(Schema.positive());
const EvenNumber = Schema.Number.pipe(
Schema.filter((n) => n % 2 === 0, {
message: () => "Expected even number",
}),
);
Branded Types
const UserId = Schema.String.pipe(Schema.brand("UserId"));
type UserId = Schema.Schema.Type<typeof UserId>;
const OrderId = Schema.Number.pipe(Schema.int(), Schema.positive(), Schema.brand("OrderId"));
Class-Based Schemas
class Person extends Schema.Class<Person>("Person")({
id: Schema.Number,
name: Schema.String,
email: Schema.String,
}) {
get displayName() {
return `${this.name} (${this.email})`;
}
}
const person = Schema.decodeUnknownSync(Person)({
id: 1,
name: "Alice",
email: "alice@example.com",
});
console.log(person.displayName);
Tagged Errors with Schema
class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", { userId: Schema.String }) {}
class ValidationError extends Schema.TaggedError<ValidationError>()("ValidationError", {
errors: Schema.Array(Schema.String),
}) {}
Annotations
const User = Schema.Struct({
id: Schema.Number.pipe(
Schema.annotations({
identifier: "UserId",
title: "User ID",
description: "Unique user identifier",
examples: [1, 2, 3],
}),
),
email: Schema.String.pipe(
Schema.annotations({
identifier: "Email",
description: "User email address",
}),
),
});
Error Messages
Custom Messages
const Password = Schema.String.pipe(
Schema.minLength(8, {
message: () => "Password must be at least 8 characters",
}),
Schema.pattern(/[A-Z]/, {
message: () => "Password must contain uppercase letter",
}),
Schema.pattern(/[0-9]/, {
message: () => "Password must contain a number",
}),
);
Formatting Errors
import { TreeFormatter, ArrayFormatter } from "effect/ParseResult";
const result = Schema.decodeUnknownEither(User)(input);
Either.match(result, {
onLeft: (error) => {
console.log(TreeFormatter.formatErrorSync(error));
console.log(ArrayFormatter.formatErrorSync(error));
},
onRight: () => {
},
});
JSON Schema Export
import { JSONSchema } from "effect";
const jsonSchema = JSONSchema.make(User);
Common Patterns
API Response Validation
const ApiResponse = <A>(dataSchema: Schema.Schema<A>) =>
Schema.Struct({
success: Schema.Boolean,
data: dataSchema,
timestamp: Schema.DateFromString,
});
const UserResponse = ApiResponse(User);
Form Validation
const RegistrationForm = Schema.Struct({
username: Schema.String.pipe(Schema.minLength(3), Schema.maxLength(20)),
email: Schema.String.pipe(Schema.pattern(emailRegex)),
password: Schema.String.pipe(Schema.minLength(8)),
confirmPassword: Schema.String,
}).pipe(Schema.filter((form) => (form.password === form.confirmPassword ? undefined : "Passwords must match")));
Recursive Schemas
interface Category {
name: string;
subcategories: readonly Category[];
}
const Category: Schema.Schema<Category> = Schema.Struct({
name: Schema.String,
subcategories: Schema.Array(Schema.suspend(() => Category)),
});
Best Practices Summary
Do
- Use tagged unions over optional properties - Make states explicit
- Use Schema.Class/TaggedClass over Struct - Get methods and Schema.is() type guards
- Use Schema.is() in Match patterns - Combine validation with matching
- Brand IDs and sensitive types - Prevent mixing up values
- Annotate for documentation - Descriptions flow to JSON Schema
- Transform at boundaries - Parse external data early
Don't
- Don't use optional properties for state - Use tagged unions instead
- Don't use plain Struct for domain entities - Use Schema.Class
- Don't validate manually - Use Schema.is() with Match
- Don't mix branded types - Each ID type should be distinct
- NEVER access
._tag directly - Use Match.tag or Schema.is() instead
- NEVER extract
._tag as a type - e.g., type Tag = Foo["_tag"] is forbidden
- NEVER use
._tag in predicates - Use Schema.is(Variant) with .some()/.filter()
- NEVER use Schema.Any or Schema.Unknown to avoid writing a proper schema - These are only permitted when the value is genuinely unconstrained (e.g., caught exception causes, opaque plugin payloads). If you can describe the data shape, define a real schema.
Additional Resources
For comprehensive Schema documentation, consult ${CLAUDE_PLUGIN_ROOT}/references/llms-full.txt.
Search for these sections:
- "Introduction to Effect Schema" for overview
- "Basic Usage" for getting started
- "Transformations" for bidirectional transforms
- "Filters" for validation rules
- "Class APIs" for class-based schemas
- "Error Formatters" for error handling