| name | schema-composition |
| description | Master Effect Schema composition patterns including Schema.compose vs Schema.pipe, transformations, filters, and validation. Use this skill when working with complex schema compositions, multi-step transformations, or when you need to validate and transform data through multiple stages. |
Schema Composition Skill
Expert guidance for composing, transforming, and validating data with Effect Schema.
Core Concepts
The Schema Type
Every schema in Effect has the type signature Schema<Type, Encoded, Context> where:
- Type: The validated, decoded output type (what you get after successful decoding)
- Encoded: The raw input type (what you provide for decoding)
- Context: External dependencies required for encoding/decoding (often
never)
Example:
import { Schema } from "effect"
// Schema<number, string, never>
// ^Type ^Encoded ^Context
const NumberFromString = Schema.NumberFromString
Decoding vs Encoding
- Decoding: Transform
Encoded→Type(e.g., string "123" → number 123) - Encoding: Transform
Type→Encoded(e.g., number 123 → string "123")
Effect Schema follows "parse, don't validate" - schemas transform data into the desired format, not just check validity.
Schema.compose vs Schema.pipe
Understanding when to use compose vs pipe is fundamental to schema composition.
Schema.compose - Chaining Transformations
Use Schema.compose to chain schemas with different types at each stage. It connects the output type of one schema to the input type of another.
Type Signature:
Schema.compose: <A, B, R1>(from: Schema<B, A, R1>) =>
<C, R2>(to: Schema<C, B, R2>) => Schema<C, A, R1 | R2>
When to Use:
- Multi-step transformations where each stage changes the type
- Connecting parsing and validation steps
- Building pipelines from
Encoded → Intermediate → Type
Example - Parse and Validate:
import { Schema } from "effect"
// Split string → array, then transform array → numbers
const schema = Schema.compose(
Schema.split(","), // string → readonly string[]
Schema.Array(Schema.NumberFromString) // readonly string[] → readonly number[]
)
// Result: Schema<readonly number[], string, never>
console.log(Schema.decodeUnknownSync(schema)("1,2,3")) // [1, 2, 3]
Example - Boolean from String via Literal:
import { Schema } from "effect"
const BooleanFromString = Schema.compose(
Schema.Literal("on", "off"), // string → "on" | "off"
Schema.transform(
Schema.Literal("on", "off"),
Schema.Boolean,
{
strict: true,
decode: (s) => s === "on",
encode: (b) => b ? "on" : "off"
}
)
)
Non-strict Composition:
When type boundaries don't align perfectly, use { strict: false }:
import { Schema } from "effect"
// Without strict: false, TypeScript error
Schema.compose(
Schema.Union(Schema.Null, Schema.Literal("0")),
Schema.NumberFromString,
{ strict: false }
)
Schema.pipe - Sequential Refinements
Use Schema.pipe to apply filters and refinements to the same type. It doesn't change the type, just adds validation constraints.
When to Use:
- Adding validation rules to an existing schema
- Chaining multiple filters on the same type
- Refining without transformation
Example - Number Validation:
import { Schema } from "effect"
const PositiveInt = Schema.Number.pipe(
Schema.int(), // Ensure it's an integer
Schema.positive() // Ensure it's positive
)
// Type: Schema<number, number, never>
// Both Type and Encoded are `number`
Example - String Validation:
import { Schema } from "effect"
const ValidEmail = Schema.String.pipe(
Schema.trimmed(),
Schema.lowercased(),
Schema.minLength(5),
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
)
Key Differences
| Aspect | Schema.compose | Schema.pipe |
|---|---|---|
| Purpose | Chain transformations | Apply refinements |
| Type Change | Changes type at each stage | Type stays the same |
| Example | string → array → numbers |
number → positive number |
| Use Case | Multi-step parsing | Validation constraints |
Built-in Filters
Filters add validation constraints without changing the schema's type. They use Schema.filter() under the hood.
String Filters
import { Schema } from "effect"
// Length constraints
Schema.String.pipe(Schema.maxLength(5))
Schema.String.pipe(Schema.minLength(5))
Schema.String.pipe(Schema.nonEmptyString()) // alias: Schema.NonEmptyString
Schema.String.pipe(Schema.length(5))
Schema.String.pipe(Schema.length({ min: 2, max: 4 }))
// Pattern matching
Schema.String.pipe(Schema.pattern(/^[a-z]+$/))
Schema.String.pipe(Schema.startsWith("prefix"))
Schema.String.pipe(Schema.endsWith("suffix"))
Schema.String.pipe(Schema.includes("substring"))
// Case and whitespace validation
Schema.String.pipe(Schema.trimmed()) // No leading/trailing whitespace
Schema.String.pipe(Schema.lowercased()) // All lowercase
Schema.String.pipe(Schema.uppercased()) // All uppercase
Schema.String.pipe(Schema.capitalized()) // First letter capitalized
Schema.String.pipe(Schema.uncapitalized()) // First letter lowercase
Number Filters
import { Schema } from "effect"
// Range constraints
Schema.Number.pipe(Schema.greaterThan(5))
Schema.Number.pipe(Schema.greaterThanOrEqualTo(5))
Schema.Number.pipe(Schema.lessThan(5))
Schema.Number.pipe(Schema.lessThanOrEqualTo(5))
Schema.Number.pipe(Schema.between(-2, 2)) // Inclusive
// Type constraints
Schema.Number.pipe(Schema.int()) // alias: Schema.Int
Schema.Number.pipe(Schema.nonNaN()) // alias: Schema.NonNaN
Schema.Number.pipe(Schema.finite()) // alias: Schema.Finite
// Sign constraints
Schema.Number.pipe(Schema.positive()) // > 0, alias: Schema.Positive
Schema.Number.pipe(Schema.nonNegative()) // >= 0, alias: Schema.NonNegative
Schema.Number.pipe(Schema.negative()) // < 0, alias: Schema.Negative
Schema.Number.pipe(Schema.nonPositive()) // <= 0, alias: Schema.NonPositive
// Special constraints
Schema.Number.pipe(Schema.multipleOf(5)) // Evenly divisible
Schema.Uint8 // 8-bit unsigned (0-255)
Schema.NonNegativeInt // Non-negative integer
Array Filters
import { Schema } from "effect"
Schema.Array(Schema.Number).pipe(Schema.maxItems(2))
Schema.Array(Schema.Number).pipe(Schema.minItems(2))
Schema.Array(Schema.Number).pipe(Schema.itemsCount(2))
Date Filters
import { Schema } from "effect"
declare const now: Date
Schema.DateFromSelf.pipe(Schema.validDate()) // alias: Schema.ValidDateFromSelf
Schema.Date.pipe(Schema.greaterThanDate(now))
Schema.Date.pipe(Schema.greaterThanOrEqualToDate(now))
Schema.Date.pipe(Schema.lessThanDate(now))
Schema.Date.pipe(Schema.lessThanOrEqualToDate(now))
Schema.Date.pipe(Schema.betweenDate(new Date(0), now))
BigInt Filters
import { Schema } from "effect"
Schema.BigInt.pipe(Schema.greaterThanBigInt(5n))
Schema.BigInt.pipe(Schema.greaterThanOrEqualToBigInt(5n))
Schema.BigInt.pipe(Schema.lessThanBigInt(5n))
Schema.BigInt.pipe(Schema.lessThanOrEqualToBigInt(5n))
Schema.BigInt.pipe(Schema.betweenBigInt(-2n, 2n))
Schema.BigInt.pipe(Schema.positiveBigInt()) // alias: Schema.PositiveBigIntFromSelf
Schema.BigInt.pipe(Schema.nonNegativeBigInt()) // alias: Schema.NonNegativeBigIntFromSelf
Schema.BigInt.pipe(Schema.negativeBigInt()) // alias: Schema.NegativeBigIntFromSelf
Schema.BigInt.pipe(Schema.nonPositiveBigInt()) // alias: Schema.NonPositiveBigIntFromSelf
BigDecimal Filters
import { BigDecimal, Schema } from "effect"
Schema.BigDecimal.pipe(Schema.greaterThanBigDecimal(BigDecimal.unsafeFromNumber(5)))
Schema.BigDecimal.pipe(Schema.lessThanBigDecimal(BigDecimal.unsafeFromNumber(5)))
Schema.BigDecimal.pipe(Schema.betweenBigDecimal(
BigDecimal.unsafeFromNumber(-2),
BigDecimal.unsafeFromNumber(2)
))
Schema.BigDecimal.pipe(Schema.positiveBigDecimal())
Schema.BigDecimal.pipe(Schema.nonNegativeBigDecimal())
Schema.BigDecimal.pipe(Schema.negativeBigDecimal())
Schema.BigDecimal.pipe(Schema.nonPositiveBigDecimal())
Duration Filters
import { Schema } from "effect"
Schema.Duration.pipe(Schema.greaterThanDuration("5 seconds"))
Schema.Duration.pipe(Schema.lessThanDuration("5 seconds"))
Schema.Duration.pipe(Schema.betweenDuration("5 seconds", "10 seconds"))
Custom Filters
Define custom validation logic using Schema.filter():
import { Schema } from "effect"
const LongString = Schema.String.pipe(
Schema.filter(
(s) => s.length >= 10 || "a string at least 10 characters long"
)
)
Filter Return Types
The filter predicate can return:
| Return Type | Meaning |
|---|---|
true or undefined |
Validation passes |
false |
Validation fails (no error message) |
string |
Validation fails with error message |
ParseResult.ParseIssue |
Validation fails with detailed error |
FilterIssue |
Validation fails with path and message |
ReadonlyArray<FilterOutput> |
Multiple validation errors |
Filter Annotations
Add metadata to filters for better error messages:
import { Schema } from "effect"
const LongString = Schema.String.pipe(
Schema.filter(
(s) => s.length >= 10 ? undefined : "a string at least 10 characters long",
{
identifier: "LongString",
jsonSchema: { minLength: 10 },
description: "A string with at least 10 characters"
}
)
)
Error Paths for Form Validation
Associate errors with specific fields using path:
import { Schema } from "effect"
const Password = Schema.Trim.pipe(Schema.minLength(2))
const MyForm = Schema.Struct({
password: Password,
confirm_password: Password
}).pipe(
Schema.filter((input) => {
if (input.password !== input.confirm_password) {
return {
path: ["confirm_password"],
message: "Passwords do not match"
}
}
})
)
Multiple Error Reporting
Return an array of issues to report multiple errors:
import { Schema } from "effect"
const Password = Schema.Trim.pipe(Schema.minLength(2))
Schema.Struct({
password: Password,
confirm_password: Password,
name: Schema.optional(Schema.String),
surname: Schema.optional(Schema.String)
}).pipe(
Schema.filter((input) => {
const issues: Array<Schema.FilterIssue> = []
if (input.password !== input.confirm_password) {
issues.push({
path: ["confirm_password"],
message: "Passwords do not match"
})
}
if (!input.name && !input.surname) {
issues.push({
path: ["surname"],
message: "Surname must be present if name is not present"
})
}
return issues
})
)
Effectful Filters
Use Schema.filterEffect for async validation:
import { Effect, Schema } from "effect"
async function validateUsername(username: string) {
return Promise.resolve(username === "gcanti")
}
const ValidUsername = Schema.String.pipe(
Schema.filterEffect((username) =>
Effect.promise(() =>
validateUsername(username).then(
(valid) => valid || "Invalid username"
)
)
)
).annotations({ identifier: "ValidUsername" })
Built-in Transformations
Transformations change data from one type to another, unlike filters which only validate.
String Transformations
import { Schema } from "effect"
// Whitespace and case transformations
Schema.Trim // Remove leading/trailing whitespace
Schema.Lowercase // Convert to lowercase
Schema.Uppercase // Convert to uppercase
Schema.Capitalize // Capitalize first character
Schema.Uncapitalize // Uncapitalize first character
// Parsing transformations
Schema.split(",") // Split string into array
Schema.parseJson() // Parse JSON string to unknown
// Schema.parseJson(schema) requires a schema parameter - see Advanced Composition Patterns
// Encoding transformations
Schema.StringFromBase64 // Decode base64 to UTF-8
Schema.StringFromBase64Url // Decode base64 URL to UTF-8
Schema.StringFromHex // Decode hex to UTF-8
Schema.StringFromUriComponent // Decode URI component to UTF-8
Example:
import { Schema } from "effect"
const decode = Schema.decodeUnknownSync(Schema.Trim)
console.log(decode(" hello ")) // "hello"
Number Transformations
import { Schema } from "effect"
// Parse numbers from strings
Schema.NumberFromString // "123" → 123 (supports "NaN", "Infinity", "-Infinity")
Boolean Transformations
import { Schema } from "effect"
// Transform various values to boolean
Schema.Not // Negation: boolean → boolean
Common Transformation Patterns
URL Parsing:
import { Schema } from "effect"
// Parse strings into URL objects
const schema = Schema.URL
Schema.decodeUnknownSync(schema)("https://example.com")
// Output: URL { href: 'https://example.com/', ... }
Date Parsing:
import { Schema } from "effect"
// Parse strings into Date objects
const schema = Schema.Date
Schema.decodeUnknownSync(schema)("2020-01-01")
// Output: Date object
Custom Transformations
Schema.transform - Simple Transformations
Use Schema.transform when the transformation always succeeds:
import { Schema } from "effect"
const BooleanFromString = Schema.transform(
Schema.Literal("on", "off"), // Source schema
Schema.Boolean, // Target schema
{
strict: true, // Optional: better TypeScript errors
decode: (literal) => literal === "on",
encode: (bool) => bool ? "on" : "off"
}
)
Key Points:
decodetransforms from source output to target inputencodetransforms from target type back to source type- Use
strict: truefor better TypeScript error messages
Schema.transformOrFail - Transformations That Can Fail
Use Schema.transformOrFail when transformation might fail:
import { ParseResult, Schema } from "effect"
const NumberFromString = Schema.transformOrFail(
Schema.String,
Schema.Number,
{
strict: true,
decode: (input, options, ast) => {
const parsed = parseFloat(input)
if (isNaN(parsed)) {
return ParseResult.fail(
new ParseResult.Type(
ast,
input,
"Failed to convert string to number"
)
)
}
return ParseResult.succeed(parsed)
},
encode: (input, options, ast) => ParseResult.succeed(input.toString())
}
)
Async Transformations
Return an Effect for async transformations:
import { Effect, Schema, ParseResult } from "effect"
const get = (url: string): Effect.Effect<unknown, Error> =>
Effect.tryPromise({
try: () => fetch(url).then((res) => res.json()),
catch: (e) => new Error(String(e))
})
const PeopleId = Schema.String.pipe(Schema.brand("PeopleId"))
const PeopleIdFromString = Schema.transformOrFail(
Schema.String,
PeopleId,
{
strict: true,
decode: (s, _, ast) =>
Effect.mapBoth(get(`https://swapi.dev/api/people/${s}`), {
onFailure: (e) => new ParseResult.Type(ast, s, e.message),
onSuccess: () => s
}),
encode: ParseResult.succeed
}
)
One-Way Transformations
Use ParseResult.Forbidden to prevent encoding:
import { Schema, ParseResult, Redacted } from "effect"
import { createHash } from "node:crypto"
const PlainPassword = Schema.String.pipe(
Schema.minLength(6),
Schema.brand("PlainPassword")
)
const HashedPassword = Schema.String.pipe(
Schema.brand("HashedPassword")
)
const PasswordHashing = Schema.transformOrFail(
PlainPassword,
Schema.RedactedFromSelf(HashedPassword),
{
strict: true,
decode: (plainPassword) => {
const hash = createHash("sha256")
.update(plainPassword)
.digest("hex")
return ParseResult.succeed(Redacted.make(hash))
},
encode: (hashedPassword, _, ast) =>
ParseResult.fail(
new ParseResult.Forbidden(
ast,
hashedPassword,
"Encoding hashed passwords back to plain text is forbidden."
)
)
}
)
Streamlined Effect Patterns
Direct flatMap with Schema.decodeUnknown
Schema.decodeUnknown(schema) returns a function that can be passed directly to Effect.flatMap:
import { Effect, Schema } from "effect"
declare const self: Effect.Effect<unknown, unknown, unknown>
declare const schema: Schema.Schema<unknown, unknown, never>
declare const toError: (e: unknown) => unknown
// ❌ Verbose
self.pipe(
Effect.flatMap((value) =>
Schema.decodeUnknown(schema)(value).pipe(
Effect.mapError(toError)
)
)
)
// ✅ Streamlined
self.pipe(
Effect.flatMap(Schema.decodeUnknown(schema)),
Effect.mapError(toError)
)
Extract Schema Factories
Create reusable schema factories for common patterns:
import { Effect, Schema } from "effect"
declare const toAssertionError: (e: unknown) => Error
const createGreaterThanSchema = (n: number) =>
Schema.Number.pipe(Schema.greaterThan(n))
export const beGreaterThan = (n: number) =>
<E, R>(self: Effect.Effect<number, E, R>) =>
self.pipe(
Effect.flatMap(Schema.decodeUnknown(createGreaterThanSchema(n))),
Effect.mapError(toAssertionError)
)
Reuse Composed Schemas
Define schemas once and reuse them:
import { Effect, Schema } from "effect"
declare const toAssertionError: (e: unknown) => Error
const TruthySchema = Schema.compose(Schema.BooleanFromUnknown, Schema.Literal(true))
export const beTruthy = () =>
<E, R>(self: Effect.Effect<unknown, E, R>) =>
self.pipe(
Effect.flatMap(Schema.decodeUnknown(TruthySchema)),
Effect.mapError(toAssertionError)
)
Decoding and Encoding
Decoding APIs
| API | Return Type | Use Case |
|---|---|---|
decodeUnknownSync |
Type (throws on error) |
Sync decoding, immediate error |
decodeUnknownOption |
Option<Type> |
Sync decoding, no error details |
decodeUnknownEither |
Either<ParseError, Type> |
Sync decoding, error handling |
decodeUnknownPromise |
Promise<Type> |
Async decoding |
decodeUnknown |
Effect<Type, ParseError, Context> |
Full Effect-based decoding |
Example:
import { Schema, Either, Effect } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
// Sync with error throwing
const person1 = Schema.decodeUnknownSync(Person)({ name: "Alice", age: 30 })
// Sync with Either
const result = Schema.decodeUnknownEither(Person)({ name: "Alice", age: 30 })
if (Either.isRight(result)) {
console.log(result.right)
}
// Effect-based (required for async schemas)
declare const asyncSchema: Schema.Schema<unknown, unknown, unknown>
declare const data: unknown
const asyncResult = Schema.decodeUnknown(asyncSchema)(data)
Effect.runPromise(asyncResult).then(console.log)
Encoding APIs
| API | Return Type | Use Case |
|---|---|---|
encodeSync |
Encoded (throws on error) |
Sync encoding, immediate error |
encodeOption |
Option<Encoded> |
Sync encoding, no error details |
encodeEither |
Either<ParseError, Encoded> |
Sync encoding, error handling |
encodePromise |
Promise<Encoded> |
Async encoding |
encode |
Effect<Encoded, ParseError, Context> |
Full Effect-based encoding |
Example:
import { Schema } from "effect"
const Person = Schema.Struct({
name: Schema.NonEmptyString,
age: Schema.NumberFromString
})
// Encode: number 30 → string "30"
console.log(Schema.encodeSync(Person)({ name: "Alice", age: 30 }))
// Output: { name: "Alice", age: "30" }
Advanced Composition Patterns
Combining Arrays and Transformations
import { Schema } from "effect"
const ReadonlySetFromArray = <A, I, R>(
itemSchema: Schema.Schema<A, I, R>
): Schema.Schema<ReadonlySet<A>, ReadonlyArray<I>, R> =>
Schema.transform(
Schema.Array(itemSchema),
// Use Schema.typeSchema to avoid double decoding
Schema.ReadonlySetFromSelf(Schema.typeSchema(itemSchema)),
{
strict: true,
decode: (items) => new Set(items),
encode: (set) => Array.from(set.values())
}
)
const schema = ReadonlySetFromArray(Schema.String)
// Schema<ReadonlySet<string>, readonly string[], never>
Multi-Stage Transformations
import { Schema } from "effect"
const BooleanFromString = Schema.transform(
Schema.Literal("on", "off"),
Schema.Boolean,
{
strict: true,
decode: (s) => s === "on",
encode: (bool) => bool ? "on" : "off"
}
)
const BooleanFromNumericString = Schema.transform(
Schema.NumberFromString, // string → number
BooleanFromString, // "on"|"off" → boolean
{
strict: true,
decode: (n) => n > 0 ? "on" : "off",
encode: (bool) => bool === "on" ? 1 : -1
}
)
// Result: Schema<boolean, string, never>
Conditional Transformations (Non-strict)
When types don't align perfectly, use strict: false:
import { Schema, Number } from "effect"
const clamp = (minimum: number, maximum: number) =>
<A extends number, I, R>(self: Schema.Schema<A, I, R>) =>
Schema.transform(
self,
self.pipe(
Schema.typeSchema,
Schema.filter((a) => a >= minimum && a <= maximum)
),
{
strict: false, // Relax type constraints
decode: (a) => Number.clamp(a, { minimum, maximum }),
encode: (a) => a
}
)
Struct and Object Schemas
Basic Struct
import { Schema } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
// Type: { readonly name: string; readonly age: number }
Optional Fields
import { Schema } from "effect"
const User = Schema.Struct({
username: Schema.String,
email: Schema.optional(Schema.String)
})
// Type: { readonly username: string; readonly email?: string | undefined }
Nullable Fields
import { Schema } from "effect"
const Data = Schema.Struct({
value: Schema.NullOr(Schema.String)
})
// Type: { readonly value: string | null }
Partial and Required
import { Schema } from "effect"
const User = Schema.Struct({
username: Schema.String,
email: Schema.optional(Schema.String)
})
// Make all fields optional
const PartialUser = Schema.partial(User)
// Make all fields required
const RequiredUser = Schema.required(PartialUser)
Picking and Omitting
import { Schema } from "effect"
const Recipe = Schema.Struct({
id: Schema.String,
name: Schema.String,
ingredients: Schema.Array(Schema.String)
})
const JustTheName = Recipe.pick("name")
const NoIDRecipe = Recipe.omit("id")
Extending Structs
import { Schema } from "effect"
const Dog = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
// Method 1: Using extend
const DogWithBreed = Dog.pipe(
Schema.extend(Schema.Struct({ breed: Schema.String }))
)
// Method 2: Spreading fields (recommended)
const DogWithBreed2 = Schema.Struct({
...Dog.fields,
breed: Schema.String
})
Excess Property Handling
import { Schema } from "effect"
const person = Schema.Struct({
name: Schema.String
})
// Preserve extra properties
Schema.decodeUnknownSync(person)(
{ name: "bob dylan", extraKey: 61 },
{ onExcessProperty: "preserve" }
)
// Output: { name: "bob dylan", extraKey: 61 }
// Error on extra properties
Schema.decodeUnknownSync(person)(
{ name: "bob dylan", extraKey: 61 },
{ onExcessProperty: "error" }
)
// Throws ParseError
Common Patterns
Email Validation
import { Schema } from "effect"
const Email = Schema.String.pipe(
Schema.lowercased(),
Schema.trimmed(),
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
)
UUID Validation
import { Schema } from "effect"
const UserId = Schema.UUID.pipe(
Schema.brand("UserId")
)
Clamping Numbers
import { Schema } from "effect"
const Percentage = Schema.Number.pipe(
Schema.between(0, 100),
Schema.brand("Percentage")
)
Template Literal Parsing
import { Schema } from "effect"
// Parse Bearer tokens
const AuthToken = Schema.TemplateLiteralParser(
"Bearer ",
Schema.String.pipe(Schema.brand("Token"))
)
// Decodes: "Bearer abc123" → ["Bearer ", "abc123"]
Branded Types
import { Schema } from "effect"
const PositiveInt = Schema.Number.pipe(
Schema.int(),
Schema.positive(),
Schema.brand("PositiveInt")
)
// Type: number & Brand<"PositiveInt">
Form Validation
import { Schema } from "effect"
const LoginForm = Schema.Struct({
email: Schema.String.pipe(
Schema.lowercased(),
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
),
password: Schema.String.pipe(
Schema.minLength(8),
Schema.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
)
})
API Response Parsing
import { Schema } from "effect"
const User = Schema.Struct({
id: Schema.NumberFromString,
name: Schema.String,
email: Schema.String,
createdAt: Schema.DateFromString
})
const UsersResponse = Schema.Struct({
users: Schema.Array(User),
total: Schema.Number
})
Quality Checklist
When creating schemas, ensure:
- Use
Schema.composefor type transformations,Schema.pipefor refinements - Prefer built-in schemas (Positive, NonEmptyString, etc.) over custom filters
- Extract reusable schemas as constants or factory functions
- Use
Schema.decodeUnknowndirectly inEffect.flatMap(no wrapper lambda) - Place error mapping outside
flatMapfor cleaner composition - Use
strict: truefor better TypeScript error messages in transformations - Add annotations (identifier, description) to custom filters
- Use
Schema.typeSchemawhen composing to avoid double decoding - Handle async operations with
Schema.decodeUnknown, not sync alternatives - Return detailed error paths for form validation
- Use branded types for domain-specific values
- Validate both structure (type) and constraints (filters)
Key Principles
- Composition over custom logic - Leverage
Schema.composeandSchema.pipeinstead of manual validation - Reusability - Extract schemas as constants or factory functions
- Type safety - Let Schema handle type inference and refinement
- Streamlined Effect chains - Minimize lambda wrappers, use direct function passing
- Built-in schemas first - Use Effect's built-in schemas before creating custom ones
- Parse, don't validate - Transform data into the desired format, not just check it
- Fail fast, fail clearly - Provide detailed error messages with paths and context
References
- Effect Schema is imported from
effect/Schemaor{ Schema } from "effect" - Schema API signature:
Schema<Type, Encoded, Context> - All schemas return
readonlytypes by default - Use
Schema.asSchemato view any schema asSchema<Type, Encoded, Context> - Access base schema before filter with
.fromproperty - Access struct fields with
.fieldsproperty