Guideline
To convert data from one type to another as part of the validation process, use Schema.transform. This allows you to define a schema that parses an input type (e.g., string) and outputs a different, richer domain type (e.g., Date).
Rationale
Often, the data you receive from external sources (like an API) isn't in the ideal format for your application's domain model. For example, dates are sent as ISO strings, but you want to work with Date objects.
Schema.transform integrates this conversion directly into the parsing step. It takes two functions: one to decode the input type into the domain type, and one to encode it back. This makes your schema the single source of truth for both the shape and the type transformation of your data.
For transformations that can fail (like creating a branded type), you can use Schema.transformOrFail, which allows the decoding step to return an Either.
Good Example 1: Parsing a Date String
This schema parses a string but produces a Date object, making the final data structure much more useful.
import { Schema, Effect } from "effect";
// Define types for better type safety
type RawEvent = {
name: string;
timestamp: string;
};
type ParsedEvent = {
name: string;
timestamp: Date;
};
// Define the schema for our event
const ApiEventSchema = Schema.Struct({
name: Schema.String,
timestamp: Schema.String,
});
// Example input
const rawInput: RawEvent = {
name: "User Login",
timestamp: "2025-06-22T20:08:42.000Z",
};
// Parse and transform
const program = Effect.gen(function* () {
const parsed = yield* Schema.decode(ApiEventSchema)(rawInput);
return {
name: parsed.name,
timestamp: new Date(parsed.timestamp),
} as ParsedEvent;
});
const programWithLogging = Effect.gen(function* () {
try {
const event = yield* program;
yield* Effect.log(`Event year: ${event.timestamp.getFullYear()}`);
yield* Effect.log(`Full event: ${JSON.stringify(event, null, 2)}`);
return event;
} catch (error) {
yield* Effect.logError(`Failed to parse event: ${error}`);
throw error;
}
}).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
yield* Effect.logError(`Program error: ${error}`);
return null;
})
)
);
Effect.runPromise(programWithLogging);
Good Example 2: Creating a Branded Type
transformOrFail is perfect for creating branded types, as the validation can fail.
import { Schema, Effect, Brand, Either } from "effect";
type Email = string & Brand.Brand<"Email">;
const Email = Schema.string.pipe(
Schema.transformOrFail(
Schema.brand<Email>("Email"),
(s, _, ast) =>
s.includes("@")
? Either.right(s as Email)
: Either.left(Schema.ParseError.create(ast, "Invalid email format")),
(email) => Either.right(email)
)
);
const result = Schema.decode(Email)("paul@example.com"); // Succeeds
const errorResult = Schema.decode(Email)("invalid-email"); // Fails
Anti-Pattern
Performing validation and transformation in two separate steps. This is more verbose, requires creating intermediate types, and separates the validation logic from the transformation logic.
import { Schema, Effect } from "effect";
// ❌ WRONG: Requires an intermediate "Raw" type.
const RawApiEventSchema = Schema.Struct({
name: Schema.String,
timestamp: Schema.String,
});
const rawInput = { name: "User Login", timestamp: "2025-06-22T20:08:42.000Z" };
// The logic is now split into two distinct, less cohesive steps.
const program = Schema.decode(RawApiEventSchema)(rawInput).pipe(
Effect.map((rawEvent) => ({
...rawEvent,
timestamp: new Date(rawEvent.timestamp), // Manual transformation after parsing.
}))
);