as User on an incoming API payload, you are lying to the compiler. TypeScript disappears at runtime. To maintain a pure domain, you must validate all data at the boundary using @effect/schema.TL;DR (Quick Summary)#
- The I/O Problem: TypeScript types are erased at runtime. If an API returns
{ "age": "twenty" }instead of a number, your code will crash deep in the business logic. - What is
@effect/schema?: A first-party validation library (similar to Zod) deeply integrated with the Effect ecosystem. - Defining Schemas: Use
Schema.Struct,Schema.String, andSchema.Numberto create runtime definitions of your data. - Parsing Data: Use
Schema.decodeUnknown(MySchema)to parse rawunknowndata. If it fails, it returns a typedParseErrordirectly into theEchannel of your Effect!
1. Introduction: The Boundary Problem#
Functional Architecture relies on Trust. If a pure function expects a User object, it trusts that the object is perfectly formed.
But where do User objects come from? They come from HTTP requests, Redis caches, and SQL databases. These are external, untyped boundaries.
// 🔴 The Unsafe Lie
async function fetchUser() {
const res = await fetch("/api/user");
const data = await res.json();
// LIE! You have no idea if 'data' actually matches the User interface!
return data as User;
}If the API changes the age field from a number to a string, the as User assertion blindly accepts it. Ten files later, a function tries to do user.age * 2, yielding NaN, and destroying the integrity of your application.
We must validate data exactly at the boundary.
2. Introducing @effect/schema#
While libraries like Zod or Yup are fantastic, they throw exceptions when validation fails. This breaks our pure Effect<A, E, R> pipeline.
@effect/schema is designed specifically for Effect-TS. When validation fails, it doesn’t throw; it gracefully yields a ParseError into the E channel.
Step 1: Installation#
npm install @effect/schemaStep 2: Defining the Schema#
Instead of defining a TypeScript interface, we define a runtime Schema. We can then extract the TypeScript type from the Schema automatically!
import { Schema } from "@effect/schema";
// 🟢 1. Define the runtime validation rules
export const UserSchema = Schema.Struct({
id: Schema.UUID, // Enforces valid UUID strings!
name: Schema.String,
age: Schema.Number,
isActive: Schema.optional(Schema.Boolean) // Optional fields
});
// 🟢 2. Extract the TypeScript type for compile-time usage
export type User = Schema.Schema.Type<typeof UserSchema>;
// The extracted type looks exactly like this:
// type User = {
// readonly id: string;
// readonly name: string;
// readonly age: number;
// readonly isActive?: boolean;
// }
3. Step-by-Step: Parsing I/O Data#
Now let’s replace our unsafe fetchUser function with a perfectly safe Effect pipeline.
We will use Schema.decodeUnknown. This function takes raw, unknown data, validates it against our schema, and returns an Effect.
import { Effect } from "effect";
import { Schema } from "@effect/schema";
const fetchValidUser = (id: string) => Effect.pipe(
// 1. Fetch the data safely
Effect.tryPromise({
try: () => fetch(`/api/users/${id}`).then(res => res.json()),
catch: () => new Error("NetworkFailure")
}),
// 2. Validate the data
// decodeUnknown returns Effect<User, ParseError, never>
Effect.flatMap(rawData => Schema.decodeUnknown(UserSchema)(rawData))
);If you hover over fetchValidUser, the compiler infers the exact signature:
Effect.Effect<User, Error | ParseError, never>If the API returns { "age": "twenty" }, the pipeline halts immediately and the ParseError drops into the E channel. You can then use Effect.catchTag("ParseError", ...) (from Episode 64) to log the exact validation failure and return a 400 Bad Request to the client.
4. Advanced Refinements (Branded Types)#
Sometimes checking if a variable is a string isn’t enough. Is it a valid email? Is it a string longer than 5 characters?
Schema allows you to add Refinements.
const EmailSchema = Schema.String.pipe(
Schema.nonEmptyString(),
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
);
const ValidatedUserSchema = Schema.Struct({
email: EmailSchema,
age: Schema.Number.pipe(Schema.greaterThanOrEqualTo(18))
});If you try to decode { email: "not-an-email", age: 10 }, the ParseError will explicitly tell you exactly which refinement failed!
5. Comparison: Zod vs Effect Schema#
| Feature | Zod (z) | Effect Schema (Schema) |
|---|---|---|
| Failure Behavior | Throws exceptions (z.parse) or returns objects (z.safeParse). | Native Effect integration. Yields ParseError to the E channel. |
| Decoding vs Encoding | 1-way parsing (Unknown -> Type). | 2-way parsing. Can encode Types back into Unknown (e.g. Dates to strings). |
| Ecosystem | Standalone. Requires wrappers to work with functional pipelines. | 100% native. Works flawlessly with Effect.flatMap. |
6. Troubleshooting & Common Errors#
Error 1: The ParseError Leaks to the UI#
The Mistake: You parse data from a Database, it fails with a ParseError, and your Express server sends the raw ParseError object to the frontend.
The Cause: ParseError objects contain deep Abstract Syntax Tree (AST) information about exactly what failed. They are not easily serialized to JSON.
The Fix: Always use TreeFormatter to convert a ParseError into a human-readable string before logging it or sending it to the client.
import { TreeFormatter } from "@effect/schema";
Effect.catchTag("ParseError", (error) => {
const readableMessage = TreeFormatter.formatErrorSync(error);
console.error("Validation failed:", readableMessage);
return Effect.fail(new Error("Invalid API Payload"));
})Error 2: Excess Properties#
The Cause: By default, if an API sends { name: "A", hack: "DROP TABLE" } and your schema only requires name, @effect/schema will strip out the hack property (similar to Zod).
The Fix: If you want the validation to explicitly fail when unknown properties are present, you must configure the decoder: Schema.decodeUnknown(UserSchema, { onExcessProperty: "error" }).
Summary & Next Steps#
In this episode:
- We identified the danger of trusting external APIs and using
as Typeassertions. - We defined runtime schemas using
@effect/schema. - We extracted perfectly typed TypeScript definitions from our schemas.
- We used
Schema.decodeUnknownto safely validate data and pushParseErrors into theEchannel.
We can now build pipelines that are async, concurrent, perfectly typed, and fully validated. But what about managing resources? If we open a database connection or a file stream, how do we guarantee it gets closed, even if the pipeline crashes halfway through?
In Episode 69: Managing Resources and Scope, we will learn how to prevent memory and connection leaks using the Effect Scope API!

