schema-composition
from front-depiction/claude-setup
Reusable Claude Code configuration for Effect TypeScript projects with specialized agents and skills
npx skills add https://github.com/front-depiction/claude-setup --skill schema-compositionSKILL.md
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.uncapitalize
...